diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..fee0b32 --- /dev/null +++ b/.env.example @@ -0,0 +1,33 @@ +# BentoPDF Environment Variables +# Copy this file to .env.production and configure as needed. + +# CORS Proxy for digital signature certificate chain fetching +VITE_CORS_PROXY_URL= +VITE_CORS_PROXY_SECRET= + +# WASM Module URLs +# Pre-configured defaults enable advanced PDF features out of the box. +# For air-gapped / offline deployments, point these to your internal server (e.g., /wasm/pymupdf/). +VITE_WASM_PYMUPDF_URL=https://cdn.jsdelivr.net/npm/@bentopdf/pymupdf-wasm@0.11.16/ +VITE_WASM_GS_URL=https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm/assets/ +VITE_WASM_CPDF_URL=https://cdn.jsdelivr.net/npm/coherentpdf/dist/ + +# OCR assets (optional) +# Set all three together for self-hosted or air-gapped OCR. +# Leave empty to use Tesseract.js runtime defaults. +VITE_TESSERACT_WORKER_URL= +VITE_TESSERACT_CORE_URL= +VITE_TESSERACT_LANG_URL= +VITE_TESSERACT_AVAILABLE_LANGUAGES= +VITE_OCR_FONT_BASE_URL= + +# Default UI language (build-time) +# Supported: en, ar, be, fr, de, es, zh, zh-TW, vi, tr, id, it, pt, nl, da +VITE_DEFAULT_LANGUAGE= + +# Custom branding (build-time) +# Replace the default BentoPDF branding with your own. +# Place your logo file in the public/ folder and set the path relative to it. +VITE_BRAND_NAME= +VITE_BRAND_LOGO= +VITE_FOOTER_TEXT= diff --git a/.github/workflows/build-and-publish.yml b/.github/workflows/build-and-publish.yml index 7d9012d..b11f990 100644 --- a/.github/workflows/build-and-publish.yml +++ b/.github/workflows/build-and-publish.yml @@ -12,7 +12,7 @@ jobs: # New job to build dist and create release build-and-release: runs-on: ubuntu-latest - if: startsWith(github.ref, 'refs/tags/') + if: github.repository == 'alam00000/bentopdf' && startsWith(github.ref, 'refs/tags/') permissions: contents: write env: @@ -54,6 +54,7 @@ jobs: # Build each platform natively in parallel, then merge manifests build-amd64: runs-on: ubuntu-latest + if: github.repository == 'alam00000/bentopdf' permissions: contents: read packages: write @@ -73,12 +74,6 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - - name: Login to DockerHub - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_TOKEN }} - - name: Login to GitHub Container Registry uses: docker/login-action@v3 with: @@ -109,7 +104,6 @@ jobs: build-args: | SIMPLE_MODE=${{ matrix.mode.simple_mode }} tags: | - bentopdf/bentopdf${{ matrix.mode.suffix }}:${{ steps.version.outputs.version }}-amd64 ghcr.io/${{ github.repository_owner }}/bentopdf${{ matrix.mode.suffix }}:${{ steps.version.outputs.version }}-amd64 platforms: linux/amd64 cache-from: type=gha,scope=amd64-${{ matrix.mode.name }} @@ -123,7 +117,6 @@ jobs: build-args: | SIMPLE_MODE=${{ matrix.mode.simple_mode }} tags: | - bentopdf/bentopdf${{ matrix.mode.suffix }}:edge-amd64 ghcr.io/${{ github.repository_owner }}/bentopdf${{ matrix.mode.suffix }}:edge-amd64 platforms: linux/amd64 cache-from: type=gha,scope=amd64-${{ matrix.mode.name }} @@ -150,12 +143,6 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - - name: Login to DockerHub - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_TOKEN }} - - name: Login to GitHub Container Registry uses: docker/login-action@v3 with: @@ -186,7 +173,6 @@ jobs: build-args: | SIMPLE_MODE=${{ matrix.mode.simple_mode }} tags: | - bentopdf/bentopdf${{ matrix.mode.suffix }}:${{ steps.version.outputs.version }}-arm64 ghcr.io/${{ github.repository_owner }}/bentopdf${{ matrix.mode.suffix }}:${{ steps.version.outputs.version }}-arm64 platforms: linux/arm64 cache-from: type=gha,scope=arm64-${{ matrix.mode.name }} @@ -200,18 +186,16 @@ jobs: build-args: | SIMPLE_MODE=${{ matrix.mode.simple_mode }} tags: | - bentopdf/bentopdf${{ matrix.mode.suffix }}:edge-arm64 ghcr.io/${{ github.repository_owner }}/bentopdf${{ matrix.mode.suffix }}:edge-arm64 platforms: linux/arm64 cache-from: type=gha,scope=arm64-${{ matrix.mode.name }} cache-to: type=gha,mode=max,scope=arm64-${{ matrix.mode.name }} - # Merge manifests after both platforms are built - merge-manifests: + # Merge GHCR manifests after both platforms are built + merge-manifests-ghcr: runs-on: ubuntu-latest needs: [build-amd64, build-arm64] permissions: - contents: write packages: write strategy: matrix: @@ -221,12 +205,6 @@ jobs: - name: simple suffix: "-simple" steps: - - name: Login to DockerHub - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_TOKEN }} - - name: Login to GitHub Container Registry uses: docker/login-action@v3 with: @@ -249,52 +227,98 @@ jobs: echo "is_release=false" >> $GITHUB_OUTPUT fi - - name: Create and push manifest (release) + - name: Create and push GHCR manifest (release) if: steps.version.outputs.is_release == 'true' run: | - # DockerHub manifests - docker buildx imagetools create -t bentopdf/bentopdf${{ matrix.mode.suffix }}:latest \ - bentopdf/bentopdf${{ matrix.mode.suffix }}:${{ steps.version.outputs.version }}-amd64 \ - bentopdf/bentopdf${{ matrix.mode.suffix }}:${{ steps.version.outputs.version }}-arm64 - - docker buildx imagetools create -t bentopdf/bentopdf${{ matrix.mode.suffix }}:${{ steps.version.outputs.version }} \ - bentopdf/bentopdf${{ matrix.mode.suffix }}:${{ steps.version.outputs.version }}-amd64 \ - bentopdf/bentopdf${{ matrix.mode.suffix }}:${{ steps.version.outputs.version }}-arm64 - - docker buildx imagetools create -t bentopdf/bentopdf${{ matrix.mode.suffix }}:${{ steps.version.outputs.version_without_v }} \ - bentopdf/bentopdf${{ matrix.mode.suffix }}:${{ steps.version.outputs.version }}-amd64 \ - bentopdf/bentopdf${{ matrix.mode.suffix }}:${{ steps.version.outputs.version }}-arm64 - - # GHCR manifests docker buildx imagetools create -t ghcr.io/${{ github.repository_owner }}/bentopdf${{ matrix.mode.suffix }}:latest \ ghcr.io/${{ github.repository_owner }}/bentopdf${{ matrix.mode.suffix }}:${{ steps.version.outputs.version }}-amd64 \ ghcr.io/${{ github.repository_owner }}/bentopdf${{ matrix.mode.suffix }}:${{ steps.version.outputs.version }}-arm64 - + docker buildx imagetools create -t ghcr.io/${{ github.repository_owner }}/bentopdf${{ matrix.mode.suffix }}:${{ steps.version.outputs.version }} \ ghcr.io/${{ github.repository_owner }}/bentopdf${{ matrix.mode.suffix }}:${{ steps.version.outputs.version }}-amd64 \ ghcr.io/${{ github.repository_owner }}/bentopdf${{ matrix.mode.suffix }}:${{ steps.version.outputs.version }}-arm64 - + docker buildx imagetools create -t ghcr.io/${{ github.repository_owner }}/bentopdf${{ matrix.mode.suffix }}:${{ steps.version.outputs.version_without_v }} \ ghcr.io/${{ github.repository_owner }}/bentopdf${{ matrix.mode.suffix }}:${{ steps.version.outputs.version }}-amd64 \ ghcr.io/${{ github.repository_owner }}/bentopdf${{ matrix.mode.suffix }}:${{ steps.version.outputs.version }}-arm64 - - name: Create and push manifest (edge) + - name: Create and push GHCR manifest (edge) if: steps.version.outputs.is_release == 'false' run: | - # DockerHub manifests - docker buildx imagetools create -t bentopdf/bentopdf${{ matrix.mode.suffix }}:edge \ - bentopdf/bentopdf${{ matrix.mode.suffix }}:edge-amd64 \ - bentopdf/bentopdf${{ matrix.mode.suffix }}:edge-arm64 - - docker buildx imagetools create -t bentopdf/bentopdf${{ matrix.mode.suffix }}:sha-${{ steps.version.outputs.short_sha }} \ - bentopdf/bentopdf${{ matrix.mode.suffix }}:edge-amd64 \ - bentopdf/bentopdf${{ matrix.mode.suffix }}:edge-arm64 - - # GHCR manifests docker buildx imagetools create -t ghcr.io/${{ github.repository_owner }}/bentopdf${{ matrix.mode.suffix }}:edge \ ghcr.io/${{ github.repository_owner }}/bentopdf${{ matrix.mode.suffix }}:edge-amd64 \ ghcr.io/${{ github.repository_owner }}/bentopdf${{ matrix.mode.suffix }}:edge-arm64 - + docker buildx imagetools create -t ghcr.io/${{ github.repository_owner }}/bentopdf${{ matrix.mode.suffix }}:sha-${{ steps.version.outputs.short_sha }} \ ghcr.io/${{ github.repository_owner }}/bentopdf${{ matrix.mode.suffix }}:edge-amd64 \ ghcr.io/${{ github.repository_owner }}/bentopdf${{ matrix.mode.suffix }}:edge-arm64 + + # Copy images from GHCR to DockerHub + push-to-dockerhub: + runs-on: ubuntu-latest + needs: [merge-manifests-ghcr] + continue-on-error: true + permissions: + contents: read + packages: read + strategy: + matrix: + mode: + - name: default + suffix: "" + - name: simple + suffix: "-simple" + steps: + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Login to DockerHub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_TOKEN }} + + - name: Extract version + id: version + run: | + if [[ $GITHUB_REF == refs/tags/v* ]]; then + VERSION=${GITHUB_REF#refs/tags/} + echo "version=${VERSION}" >> $GITHUB_OUTPUT + echo "version_without_v=${VERSION#v}" >> $GITHUB_OUTPUT + echo "is_release=true" >> $GITHUB_OUTPUT + else + SHORT_SHA=${GITHUB_SHA::7} + echo "version=edge" >> $GITHUB_OUTPUT + echo "short_sha=${SHORT_SHA}" >> $GITHUB_OUTPUT + echo "is_release=false" >> $GITHUB_OUTPUT + fi + + - name: Copy images to DockerHub (release) + if: steps.version.outputs.is_release == 'true' + run: | + docker buildx imagetools create -t bentopdfteam/bentopdf${{ matrix.mode.suffix }}:latest \ + ghcr.io/${{ github.repository_owner }}/bentopdf${{ matrix.mode.suffix }}:${{ steps.version.outputs.version }}-amd64 \ + ghcr.io/${{ github.repository_owner }}/bentopdf${{ matrix.mode.suffix }}:${{ steps.version.outputs.version }}-arm64 + + docker buildx imagetools create -t bentopdfteam/bentopdf${{ matrix.mode.suffix }}:${{ steps.version.outputs.version }} \ + ghcr.io/${{ github.repository_owner }}/bentopdf${{ matrix.mode.suffix }}:${{ steps.version.outputs.version }}-amd64 \ + ghcr.io/${{ github.repository_owner }}/bentopdf${{ matrix.mode.suffix }}:${{ steps.version.outputs.version }}-arm64 + + docker buildx imagetools create -t bentopdfteam/bentopdf${{ matrix.mode.suffix }}:${{ steps.version.outputs.version_without_v }} \ + ghcr.io/${{ github.repository_owner }}/bentopdf${{ matrix.mode.suffix }}:${{ steps.version.outputs.version }}-amd64 \ + ghcr.io/${{ github.repository_owner }}/bentopdf${{ matrix.mode.suffix }}:${{ steps.version.outputs.version }}-arm64 + + - name: Copy images to DockerHub (edge) + if: steps.version.outputs.is_release == 'false' + run: | + docker buildx imagetools create -t bentopdfteam/bentopdf${{ matrix.mode.suffix }}:edge \ + ghcr.io/${{ github.repository_owner }}/bentopdf${{ matrix.mode.suffix }}:edge-amd64 \ + ghcr.io/${{ github.repository_owner }}/bentopdf${{ matrix.mode.suffix }}:edge-arm64 + + docker buildx imagetools create -t bentopdfteam/bentopdf${{ matrix.mode.suffix }}:sha-${{ steps.version.outputs.short_sha }} \ + ghcr.io/${{ github.repository_owner }}/bentopdf${{ matrix.mode.suffix }}:edge-amd64 \ + ghcr.io/${{ github.repository_owner }}/bentopdf${{ matrix.mode.suffix }}:edge-arm64 diff --git a/.github/workflows/cla.yml b/.github/workflows/cla.yml index 1c4cafd..20f831e 100644 --- a/.github/workflows/cla.yml +++ b/.github/workflows/cla.yml @@ -28,7 +28,7 @@ jobs: ) steps: - name: "CLA Assistant" - uses: cla-assistant/github-action@v2.5.0 + uses: contributor-assistant/github-action@v2.6.1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} PERSONAL_ACCESS_TOKEN: ${{ secrets.CLA_ASSISTANT_TOKEN }} diff --git a/.github/workflows/static.yml b/.github/workflows/static.yml index 52ecbd1..3013709 100644 --- a/.github/workflows/static.yml +++ b/.github/workflows/static.yml @@ -31,9 +31,10 @@ env: jobs: # Single deploy job since we're just deploying - deploy: - environment: - name: github-pages + deploy: + if: github.repository == 'alam00000/bentopdf' + environment: + name: github-pages url: ${{ steps.deployment.outputs.page_url }} runs-on: ubuntu-latest steps: diff --git a/.github/workflows/update-embedpdf-snippet.yml b/.github/workflows/update-embedpdf-snippet.yml new file mode 100644 index 0000000..3c14152 --- /dev/null +++ b/.github/workflows/update-embedpdf-snippet.yml @@ -0,0 +1,139 @@ +name: Update EmbedPDF Snippet + +on: + workflow_dispatch: + schedule: + - cron: '0 3 * * 1' # Weekly; adjust as needed + +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 c4f76c1..a89bf35 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,15 @@ dist-ssr coverage/ *.lcov +# Generated sitemap +public/sitemap.xml + #backup .seo-backup -libreoffice-wasm-package \ No newline at end of file +libreoffice-wasm-package + +# helm chart +bentopdf-*.tgz + +# test +dist-test \ No newline at end of file diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..e70c3cd --- /dev/null +++ b/.prettierignore @@ -0,0 +1,2 @@ +# Handlebars partials with template syntax inside HTML attributes +src/partials/footer.html diff --git a/Dockerfile b/Dockerfile index 7dcd0d5..1e962ad 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,7 +3,7 @@ ARG BASE_URL= # Build stage -FROM node:20-alpine AS builder +FROM public.ecr.aws/docker/library/node:20-alpine AS builder WORKDIR /app COPY package*.json ./ COPY vendor ./vendor @@ -22,18 +22,53 @@ ENV SIMPLE_MODE=$SIMPLE_MODE ARG COMPRESSION_MODE=all ENV COMPRESSION_MODE=$COMPRESSION_MODE -# global arg to local arg +# global arg to local arg - BASE_URL is read from env by vite.config.ts ARG BASE_URL ENV BASE_URL=$BASE_URL -RUN if [ -z "$BASE_URL" ]; then \ - npm run build -- --mode production; \ - else \ - npm run build -- --base=${BASE_URL} --mode production; \ - fi +# WASM module URLs (pre-configured defaults) +# Override these for air-gapped or self-hosted WASM deployments +ARG VITE_WASM_PYMUPDF_URL +ARG VITE_WASM_GS_URL +ARG VITE_WASM_CPDF_URL +ENV VITE_WASM_PYMUPDF_URL=$VITE_WASM_PYMUPDF_URL +ENV VITE_WASM_GS_URL=$VITE_WASM_GS_URL +ENV VITE_WASM_CPDF_URL=$VITE_WASM_CPDF_URL + +# OCR asset URLs (optional, used for self-hosted or air-gapped OCR) +ARG VITE_TESSERACT_WORKER_URL +ARG VITE_TESSERACT_CORE_URL +ARG VITE_TESSERACT_LANG_URL +ARG VITE_TESSERACT_AVAILABLE_LANGUAGES +ARG VITE_OCR_FONT_BASE_URL +ENV VITE_TESSERACT_WORKER_URL=$VITE_TESSERACT_WORKER_URL +ENV VITE_TESSERACT_CORE_URL=$VITE_TESSERACT_CORE_URL +ENV VITE_TESSERACT_LANG_URL=$VITE_TESSERACT_LANG_URL +ENV VITE_TESSERACT_AVAILABLE_LANGUAGES=$VITE_TESSERACT_AVAILABLE_LANGUAGES +ENV VITE_OCR_FONT_BASE_URL=$VITE_OCR_FONT_BASE_URL + +# Default UI language (e.g. en, fr, de, es, zh, ar) +ARG VITE_DEFAULT_LANGUAGE +ENV VITE_DEFAULT_LANGUAGE=$VITE_DEFAULT_LANGUAGE + +# Custom branding (e.g. VITE_BRAND_NAME=MyCompany VITE_BRAND_LOGO=my-logo.svg) +ARG VITE_BRAND_NAME +ARG VITE_BRAND_LOGO +ARG VITE_FOOTER_TEXT +ENV VITE_BRAND_NAME=$VITE_BRAND_NAME +ENV VITE_BRAND_LOGO=$VITE_BRAND_LOGO +ENV VITE_FOOTER_TEXT=$VITE_FOOTER_TEXT + +ENV NODE_OPTIONS="--max-old-space-size=3072" + +RUN --mount=type=secret,id=VITE_CORS_PROXY_URL \ + --mount=type=secret,id=VITE_CORS_PROXY_SECRET \ + VITE_CORS_PROXY_URL=$(cat /run/secrets/VITE_CORS_PROXY_URL 2>/dev/null || echo "") \ + VITE_CORS_PROXY_SECRET=$(cat /run/secrets/VITE_CORS_PROXY_SECRET 2>/dev/null || echo "") \ + npm run build:with-docs # Production stage -FROM nginxinc/nginx-unprivileged:stable-alpine-slim +FROM quay.io/nginx/nginx-unprivileged:stable-alpine-slim LABEL org.opencontainers.image.source="https://github.com/alam00000/bentopdf" LABEL org.opencontainers.image.url="https://github.com/alam00000/bentopdf" diff --git a/Dockerfile.nonroot b/Dockerfile.nonroot new file mode 100644 index 0000000..0599daf --- /dev/null +++ b/Dockerfile.nonroot @@ -0,0 +1,93 @@ +# Non-root Dockerfile — supports PUID/PGID environment variables (LSIO-style) +# Usage: docker build -f Dockerfile.nonroot -t bentopdf . +# docker run -d -p 3000:8080 -e PUID=1000 -e PGID=1000 bentopdf + +ARG BASE_URL= + +# Build stage (identical to main Dockerfile) +FROM public.ecr.aws/docker/library/node:20-alpine AS builder +WORKDIR /app +COPY package*.json ./ +COPY vendor ./vendor +ENV HUSKY=0 +RUN npm config set fetch-retries 5 && \ + npm config set fetch-retry-mintimeout 60000 && \ + npm config set fetch-retry-maxtimeout 300000 && \ + npm config set fetch-timeout 600000 && \ + npm ci +COPY . . + +ARG SIMPLE_MODE=false +ENV SIMPLE_MODE=$SIMPLE_MODE +ARG COMPRESSION_MODE=all +ENV COMPRESSION_MODE=$COMPRESSION_MODE + +ARG BASE_URL +ENV BASE_URL=$BASE_URL + +ARG VITE_WASM_PYMUPDF_URL +ARG VITE_WASM_GS_URL +ARG VITE_WASM_CPDF_URL +ENV VITE_WASM_PYMUPDF_URL=$VITE_WASM_PYMUPDF_URL +ENV VITE_WASM_GS_URL=$VITE_WASM_GS_URL +ENV VITE_WASM_CPDF_URL=$VITE_WASM_CPDF_URL + +ARG VITE_TESSERACT_WORKER_URL +ARG VITE_TESSERACT_CORE_URL +ARG VITE_TESSERACT_LANG_URL +ARG VITE_TESSERACT_AVAILABLE_LANGUAGES +ARG VITE_OCR_FONT_BASE_URL +ENV VITE_TESSERACT_WORKER_URL=$VITE_TESSERACT_WORKER_URL +ENV VITE_TESSERACT_CORE_URL=$VITE_TESSERACT_CORE_URL +ENV VITE_TESSERACT_LANG_URL=$VITE_TESSERACT_LANG_URL +ENV VITE_TESSERACT_AVAILABLE_LANGUAGES=$VITE_TESSERACT_AVAILABLE_LANGUAGES +ENV VITE_OCR_FONT_BASE_URL=$VITE_OCR_FONT_BASE_URL + +# Default UI language (e.g. en, fr, de, es, zh, ar) +ARG VITE_DEFAULT_LANGUAGE +ENV VITE_DEFAULT_LANGUAGE=$VITE_DEFAULT_LANGUAGE + +# Custom branding (e.g. VITE_BRAND_NAME=MyCompany VITE_BRAND_LOGO=my-logo.svg) +ARG VITE_BRAND_NAME +ARG VITE_BRAND_LOGO +ARG VITE_FOOTER_TEXT +ENV VITE_BRAND_NAME=$VITE_BRAND_NAME +ENV VITE_BRAND_LOGO=$VITE_BRAND_LOGO +ENV VITE_FOOTER_TEXT=$VITE_FOOTER_TEXT + +ENV NODE_OPTIONS="--max-old-space-size=3072" + +RUN --mount=type=secret,id=VITE_CORS_PROXY_URL \ + --mount=type=secret,id=VITE_CORS_PROXY_SECRET \ + VITE_CORS_PROXY_URL=$(cat /run/secrets/VITE_CORS_PROXY_URL 2>/dev/null || echo "") \ + VITE_CORS_PROXY_SECRET=$(cat /run/secrets/VITE_CORS_PROXY_SECRET 2>/dev/null || echo "") \ + npm run build:with-docs + +# Production stage — uses standard nginx (starts as root, drops to PUID/PGID) +FROM nginx:stable-alpine-slim + +LABEL org.opencontainers.image.source="https://github.com/alam00000/bentopdf" +LABEL org.opencontainers.image.url="https://github.com/alam00000/bentopdf" + +ARG BASE_URL + +ENV PUID=1000 +ENV PGID=1000 +ENV DISABLE_IPV6=false + +RUN apk add --no-cache su-exec + +COPY --from=builder /app/dist /usr/share/nginx/html${BASE_URL%/} +COPY nginx.conf /etc/nginx/nginx.conf +COPY --chmod=755 entrypoint.sh /entrypoint.sh + +RUN mkdir -p /etc/nginx/tmp \ + /var/cache/nginx/client_temp \ + /var/cache/nginx/proxy_temp \ + /var/cache/nginx/fastcgi_temp \ + /var/cache/nginx/uwsgi_temp \ + /var/cache/nginx/scgi_temp + +EXPOSE 8080 +ENTRYPOINT ["/entrypoint.sh"] +CMD ["nginx", "-g", "daemon off;"] diff --git a/README.md b/README.md index ec8dd7d..d7711b4 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,57 @@

BentoPDF

+

+ + DigitalOcean Referral Badge + +

**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. -![Docker Pulls](https://img.shields.io/docker/pulls/bentopdf/bentopdf) [![Ko-fi](https://img.shields.io/badge/Buy%20me%20a%20Coffee-yellow?logo=kofi&style=flat-square)](https://ko-fi.com/alio01) ![GitHub Stars](https://img.shields.io/github/stars/alam00000/bentopdf?style=social) +[![Docker Downloads](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fghcr-badge.elias.eu.org%2Fapi%2Falam00000%2Fbentopdf%2Fbentopdf&query=%24.downloadCount&logo=docker&label=Docker%20Downloads&color=blue)](https://github.com/alam00000/bentopdf/pkgs/container/bentopdf) [![Ko-fi](https://img.shields.io/badge/Buy%20me%20a%20Coffee-yellow?logo=kofi&style=flat-square)](https://ko-fi.com/alio01) ![GitHub Stars](https://img.shields.io/github/stars/alam00000/bentopdf?style=social) [![Sponsor me on GitHub](https://img.shields.io/badge/Sponsor-%E2%9D%A4-ff69b4)](https://github.com/sponsors/alam00000) ![BentoPDF Tools](public/images/bentopdf-tools.png) --- +## Table of Contents + +- [Join Us on Discord](#-join-us-on-discord) +- [Documentation](#-documentation) +- [Licensing](#-licensing) +- [Stargazers over time](#-stargazers-over-time) +- [Thank You to Our Sponsors](#-thank-you-to-our-sponsors) +- [Why BentoPDF?](#-why-bentopdf) +- [Features / Tools Supported](#️-features--tools-supported) + - [Organize & Manage PDFs](#organize--manage-pdfs) + - [Edit & Modify PDFs](#edit--modify-pdfs) + - [Convert to PDF](#convert-to-pdf) + - [Convert from PDF](#convert-from-pdf) + - [Secure & Optimize PDFs](#secure--optimize-pdfs) +- [Translations](#-translations) +- [Getting Started](#-getting-started) + - [Prerequisites](#prerequisites) + - [Quick Start](#-quick-start) + - [Static Hosting](#static-hosting-using-netlify-vercel-and-github-pages) + - [Self-Hosting Locally](#-self-hosting-locally) + - [Docker Compose / Podman Compose](#-run-with-docker-compose--podman-compose-recommended) + - [Podman Quadlet](#-podman-quadlet-systemd-integration) + - [Simple Mode](#-simple-mode-for-internal-use) + - [Custom Branding](#-custom-branding) + - [WASM Configuration](#wasm-configuration) + - [Air-Gapped / Offline Deployment](#air-gapped--offline-deployment) + - [Security Features](#-security-features) + - [Digital Signature CORS Proxy](#digital-signature-cors-proxy-required) + - [Version Management](#-version-management) + - [Development Setup](#-development-setup) +- [Tech Stack & Background](#️-tech-stack--background) +- [Roadmap](#️-roadmap) +- [Contributing](#-contributing) +- [Special Thanks](#special-thanks) + +--- + ## 📢 Join Us on Discord [![Discord](https://img.shields.io/badge/Discord-Join%20Server-7289da?style=for-the-badge&logo=discord&logoColor=white)](https://discord.gg/Bgq3Ay3f2w) @@ -51,6 +93,19 @@ BentoPDF is **dual-licensed** to fit your needs: 📖 For more details, see our [Licensing Page](https://bentopdf.com/licensing.html) +### AGPL Components (Pre-configured via CDN) + +BentoPDF does **not** bundle AGPL-licensed processing libraries in its source code, but **pre-configures CDN URLs** so all features work out of the box with zero setup: + +| Component | License | Features Enabled | +| ---------------------- | -------- | --------------------------------------------------------------------------------------------------- | +| **PyMuPDF** | AGPL-3.0 | PDF to Text/Markdown/SVG/DOCX, Extract Images/Tables, EPUB/MOBI/XPS conversion, Compression, Deskew | +| **Ghostscript** | AGPL-3.0 | PDF/A Conversion, Font to Outline | +| **CoherentPDF (CPDF)** | AGPL-3.0 | Merge, Split by Bookmarks, Table of Contents, PDF to/from JSON, Attachments | + +> [!TIP] +> **Zero-config by default.** WASM modules are loaded at runtime from jsDelivr CDN. No manual configuration is needed. For custom deployments (air-gapped, self-hosted), see [WASM Configuration](#wasm-configuration) below. +
## ⭐ Stargazers over time @@ -120,6 +175,7 @@ BentoPDF offers a comprehensive suite of tools to handle all your PDF needs. | **Create Fillable Forms** | Create professional fillable PDF forms with text fields, checkboxes, dropdowns, radio buttons, signatures, and more. Fully compliant with PDF standards for compatibility with all PDF viewers. | | **PDF Form Filler** | Fill in forms directly in the browser. Also supports XFA forms. | | **Add Page Numbers** | Easily add page numbers with customizable formatting. | +| **Bates Numbering** | Add sequential Bates numbers across one or more PDF files. | | **Add Watermark** | Add text or image watermarks to protect your documents. | | **Header & Footer** | Add customizable headers and footers. | | **Crop PDF** | Crop specific pages or the entire document. | @@ -135,6 +191,14 @@ BentoPDF offers a comprehensive suite of tools to handle all your PDF needs. | **Add Stamps** | Add image stamps to your PDF using the annotation toolbar. | | **Table of Contents** | Generate a table of contents page from PDF bookmarks. | | **Redact Content** | Permanently remove sensitive content from your PDFs. | +| **Scanner Effect** | Make your PDF look like a scanned document. | +| **Adjust Colors** | Fine-tune brightness, contrast, saturation and more. | + +### Automate + +| Tool Name | Description | +| :----------------------- | :--------------------------------------------------------------- | +| **PDF Workflow Builder** | Build custom PDF processing pipelines with a visual node editor. | ### Convert to PDF @@ -232,6 +296,7 @@ BentoPDF is available in multiple languages: | Portuguese | [![Portuguese](https://img.shields.io/badge/Complete-green?style=flat-square)](public/locales/pt/common.json) | | Turkish | [![Turkish](https://img.shields.io/badge/Complete-green?style=flat-square)](public/locales/tr/common.json) | | Vietnamese | [![Vietnamese](https://img.shields.io/badge/Complete-green?style=flat-square)](public/locales/vi/common.json) | +| Korean | [![Korean](https://img.shields.io/badge/Complete-green?style=flat-square)](public/locales/ko/common.json) | Want to help translate BentoPDF into your language? Check out our [Translation Guide](TRANSLATION.md)! @@ -247,22 +312,9 @@ You can run BentoPDF locally for development or personal use. - [npm](https://www.npmjs.com/) (or yarn/pnpm) - [Docker](https://www.docker.com/) & [Docker Compose](https://docs.docker.com/compose/install/) (for containerized setup) -### 🚀 Quick Start with Docker +### 🚀 Quick Start -[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/templates/K4AU2B) - -You can run BentoPDF directly from Docker Hub or GitHub Container Registry without cloning the repository: - -You can also watch the video on how to set it up 👉 -[BentoPDF Docker Setup](https://drive.google.com/file/d/1C4eJ2nqeaH__1Tlad-xuBHaF2Ha4fSBf/view?usp=drive_link) - -**Using Docker Hub:** - -```bash -docker run -p 3000:8080 bentopdf/bentopdf:latest -``` - -**Using GitHub Container Registry:** +Run BentoPDF instantly from GitHub Container Registry (Recommended): ```bash docker run -p 3000:8080 ghcr.io/alam00000/bentopdf:latest @@ -270,16 +322,43 @@ docker run -p 3000:8080 ghcr.io/alam00000/bentopdf:latest Open your browser at: http://localhost:3000 -This is the fastest way to try BentoPDF without setting up a development environment. +
+Alternative: Using Docker Hub or Podman + +**Docker Hub:** + +```bash +docker run -p 3000:8080 bentopdfteam/bentopdf:latest +``` + +**Podman (GHCR):** + +```bash +podman run -p 3000:8080 ghcr.io/alam00000/bentopdf:latest +``` + +**Podman (Docker Hub):** + +```bash +podman run -p 3000:8080 docker.io/bentopdfteam/bentopdf:latest +``` + +> [!NOTE] +> All `docker` commands in this documentation work with Podman by replacing `docker` with `podman`. + +
### Static Hosting using Netlify, Vercel, and GitHub Pages -It is very straightforward to host your own instance of BentoPDF using a static web page hosting service. Plus, services such as Netlify, Vercel, and GitHub Pages all offer a free tier for getting started. See [Static Hosting](https://github.com/alam00000/bentopdf/blob/main/STATIC-HOSTING.md)) for details. +It is very straightforward to host your own instance of BentoPDF using a static web page hosting service. Plus, services such as Netlify, Vercel, and GitHub Pages all offer a free tier for getting started. See [Static Hosting](https://github.com/alam00000/bentopdf/blob/main/STATIC-HOSTING.md) for details. ### 🏠 Self-Hosting Locally Since BentoPDF is fully client-side, all processing happens in the user's browser and no server-side processing is required. This means you can host BentoPDF as simple static files on any web server or hosting platform. +> [!IMPORTANT] +> Office file conversion uses LibreOffice WASM, which requires `SharedArrayBuffer`. That means the app must be both cross-origin isolated and served from a secure context. `http://localhost` works for local testing, but `http://192.168.x.x` or other LAN IPs usually require HTTPS even if the server already sends the correct COOP/COEP headers. + **Download from Releases (Recommended):** The easiest way to self-host is to download the pre-built distribution file from our [GitHub releases](https://github.com/alam00000/bentopdf/releases). Each release includes a `dist-{version}.zip` file that contains all necessary files for self-hosting. @@ -301,7 +380,8 @@ npx http-server -c-1 The website will be accessible at: `http://localhost:8080/` -> **Note:** The `-c-1` flag disables caching for development. +> [!NOTE] +> The `-c-1` flag disables caching for development. **Build from Source (Advanced):** @@ -375,6 +455,216 @@ npm run build - Local files are **always included** as automatic fallback - If CDN fails then it falls back to local files +

⚙️ WASM Configuration

+ +Advanced PDF features (PyMuPDF, Ghostscript, CoherentPDF) are pre-configured to load from jsDelivr CDN via environment variables. This means **all features work out of the box** — no manual setup needed. + +The default URLs are set in `.env.production`: + +```bash +VITE_WASM_PYMUPDF_URL=https://cdn.jsdelivr.net/npm/@bentopdf/pymupdf-wasm@0.11.16/ +VITE_WASM_GS_URL=https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm/assets/ +VITE_WASM_CPDF_URL=https://cdn.jsdelivr.net/npm/coherentpdf/dist/ +VITE_TESSERACT_WORKER_URL= +VITE_TESSERACT_CORE_URL= +VITE_TESSERACT_LANG_URL= +VITE_TESSERACT_AVAILABLE_LANGUAGES= +VITE_OCR_FONT_BASE_URL= +``` + +To override via Docker build args: + +```bash +docker build \ + --build-arg VITE_WASM_PYMUPDF_URL=https://your-server.com/pymupdf/ \ + --build-arg VITE_WASM_GS_URL=https://your-server.com/gs/ \ + --build-arg VITE_WASM_CPDF_URL=https://your-server.com/cpdf/ \ + --build-arg VITE_TESSERACT_WORKER_URL=https://your-server.com/ocr/worker.min.js \ + --build-arg VITE_TESSERACT_CORE_URL=https://your-server.com/ocr/core \ + --build-arg VITE_TESSERACT_LANG_URL=https://your-server.com/ocr/lang-data \ + --build-arg VITE_TESSERACT_AVAILABLE_LANGUAGES=eng,deu \ + --build-arg VITE_OCR_FONT_BASE_URL=https://your-server.com/ocr/fonts \ + -t bentopdf . +``` + +To disable a module (require manual user config via Advanced Settings), set its variable to an empty string. + +For OCR, either leave all `VITE_TESSERACT_*` variables empty and use the default online assets, or set the worker/core/lang URLs together for self-hosted/offline OCR. If your self-hosted bundle only includes a subset such as `eng,deu`, also set `VITE_TESSERACT_AVAILABLE_LANGUAGES=eng,deu` so the UI only shows bundled languages and OCR fails with a descriptive message for unsupported ones. For fully offline searchable-PDF output, also set `VITE_OCR_FONT_BASE_URL` to the internal directory that serves the bundled OCR text-layer fonts. + +Users can also override these defaults per-browser via **Advanced Settings** in the UI — user overrides take priority over the environment defaults. + +> [!IMPORTANT] +> These URLs are baked into the JavaScript at **build time**. The WASM files themselves are downloaded by the **user's browser** at runtime — Docker does not download them during the build. + +

🔒 Air-Gapped / Offline Deployment

+ +For networks with no internet access (government, healthcare, financial, etc.), you need to prepare everything on a machine **with** internet, then transfer the bundle into the isolated network. + +#### Automated Script (Recommended) + +The included `prepare-airgap.sh` script automates the entire process — downloading WASM packages, building the Docker image, exporting everything into a self-contained bundle with a setup script. + +```bash +git clone https://github.com/alam00000/bentopdf.git +cd bentopdf + +# Show supported OCR language codes (for --ocr-languages) +bash scripts/prepare-airgap.sh --list-ocr-languages + +# Search OCR language codes by name or abbreviation +bash scripts/prepare-airgap.sh --search-ocr-language german + +# Interactive mode — prompts for all options +bash scripts/prepare-airgap.sh + +# Or fully automated +bash scripts/prepare-airgap.sh --wasm-base-url https://internal.example.com/wasm +``` + +This produces a bundle directory containing: + +``` +bentopdf-airgap-bundle/ + bentopdf.tar # Docker image + *.tgz # WASM packages (PyMuPDF, Ghostscript, CoherentPDF, Tesseract) + tesseract-langdata/ # OCR traineddata files + ocr-fonts/ # OCR text-layer font files + setup.sh # Setup script for the air-gapped side + README.md # Instructions +``` + +**Transfer the bundle** into the air-gapped network via USB, internal artifact repo, or approved method. Then run the included setup script: + +```bash +cd bentopdf-airgap-bundle +bash setup.sh +``` + +The setup script loads the Docker image, extracts WASM files, and optionally starts the container. + +
+Script options + +| Flag | Description | Default | +| ------------------------------ | ------------------------------------------------ | --------------------------------- | +| `--wasm-base-url ` | Where WASMs will be hosted internally | _(required, prompted if missing)_ | +| `--image-name ` | Docker image tag | `bentopdf` | +| `--output-dir ` | Output bundle directory | `./bentopdf-airgap-bundle` | +| `--simple-mode` | Enable Simple Mode | off | +| `--base-url ` | Subdirectory base URL (e.g. `/pdf/`) | `/` | +| `--language ` | Default UI language (e.g. `fr`, `de`) | _(none)_ | +| `--brand-name ` | Custom brand name | _(none)_ | +| `--brand-logo ` | Logo path relative to `public/` | _(none)_ | +| `--footer-text ` | Custom footer text | _(none)_ | +| `--ocr-languages ` | Comma-separated OCR languages to bundle | `eng` | +| `--list-ocr-languages` | Print supported OCR codes and names, then exit | off | +| `--search-ocr-language ` | Search OCR codes by name or abbreviation | off | +| `--dockerfile ` | Dockerfile to use | `Dockerfile` | +| `--skip-docker` | Skip Docker build and export | off | +| `--skip-wasm` | Skip WASM download (reuse existing `.tgz` files) | off | + +
+ +The interactive prompt also accepts `list` to print the full supported Tesseract code list and `search ` to find matches such as `search german` or `search chi`. + +> [!IMPORTANT] +> WASM files must be served from the **same origin** as the BentoPDF app. Web Workers use `importScripts()` which cannot load scripts cross-origin. For example, if BentoPDF runs at `https://internal.example.com`, the WASM base URL should also be `https://internal.example.com/wasm`. + +#### Manual Steps + +
+If you prefer to do it manually without the script + +**Step 1: Download the WASM and OCR packages** (on a machine with internet) + +```bash +npm pack @bentopdf/pymupdf-wasm@0.11.16 +npm pack @bentopdf/gs-wasm +npm pack coherentpdf +npm pack tesseract.js@7.0.0 +npm pack tesseract.js-core@7.0.0 +mkdir -p tesseract-langdata +curl -fsSL https://cdn.jsdelivr.net/npm/@tesseract.js-data/eng/4.0.0_best_int/eng.traineddata.gz -o tesseract-langdata/eng.traineddata.gz +mkdir -p ocr-fonts +curl -fsSL https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSans/NotoSans-Regular.ttf -o ocr-fonts/NotoSans-Regular.ttf +``` + +**Step 2: Build the Docker image with internal URLs** + +```bash +git clone https://github.com/alam00000/bentopdf.git +cd bentopdf + +docker build \ + --build-arg VITE_WASM_PYMUPDF_URL=https://internal-server.example.com/wasm/pymupdf/ \ + --build-arg VITE_WASM_GS_URL=https://internal-server.example.com/wasm/gs/ \ + --build-arg VITE_WASM_CPDF_URL=https://internal-server.example.com/wasm/cpdf/ \ + --build-arg VITE_TESSERACT_WORKER_URL=https://internal-server.example.com/wasm/ocr/worker.min.js \ + --build-arg VITE_TESSERACT_CORE_URL=https://internal-server.example.com/wasm/ocr/core \ + --build-arg VITE_TESSERACT_LANG_URL=https://internal-server.example.com/wasm/ocr/lang-data \ + --build-arg VITE_OCR_FONT_BASE_URL=https://internal-server.example.com/wasm/ocr/fonts \ + -t bentopdf . +``` + +**Step 3: Export the Docker image** + +```bash +docker save bentopdf -o bentopdf.tar +``` + +**Step 4: Transfer into the air-gapped network** + +Copy these files via USB drive, internal artifact repository, or approved transfer method: + +- `bentopdf.tar` — the Docker image +- `bentopdf-pymupdf-wasm-0.11.14.tgz` — PyMuPDF WASM package +- `bentopdf-gs-wasm-*.tgz` — Ghostscript WASM package +- `coherentpdf-*.tgz` — CoherentPDF WASM package +- `tesseract.js-7.0.0.tgz` — Tesseract worker package +- `tesseract.js-core-7.0.0.tgz` — Tesseract core runtime package +- `tesseract-langdata/` — OCR traineddata files +- `ocr-fonts/` — OCR text-layer font files + +**Step 5: Set up inside the air-gapped network** + +```bash +# Load the Docker image +docker load -i bentopdf.tar + +# Extract the WASM packages +mkdir -p ./wasm/pymupdf ./wasm/gs ./wasm/cpdf ./wasm/ocr/core ./wasm/ocr/lang-data ./wasm/ocr/fonts +tar xzf bentopdf-pymupdf-wasm-0.11.14.tgz -C ./wasm/pymupdf --strip-components=1 +tar xzf bentopdf-gs-wasm-*.tgz -C ./wasm/gs --strip-components=1 +tar xzf coherentpdf-*.tgz -C ./wasm/cpdf --strip-components=1 +TEMP_TESS=$(mktemp -d) +tar xzf tesseract.js-7.0.0.tgz -C "$TEMP_TESS" +cp "$TEMP_TESS/package/dist/worker.min.js" ./wasm/ocr/worker.min.js +rm -rf "$TEMP_TESS" +tar xzf tesseract.js-core-7.0.0.tgz -C ./wasm/ocr/core --strip-components=1 +cp ./tesseract-langdata/*.traineddata.gz ./wasm/ocr/lang-data/ +cp ./ocr-fonts/* ./wasm/ocr/fonts/ + +# Run BentoPDF +docker run -d -p 3000:8080 --restart unless-stopped bentopdf +``` + +Make sure the files are accessible at the URLs you configured in Step 2, including `.../ocr/worker.min.js`, `.../ocr/core`, `.../ocr/lang-data`, and `.../ocr/fonts`. + +
+ +> [!NOTE] +> If you're building from source instead of Docker, set the variables in `.env.production` before running `npm run build`: +> +> ```bash +> VITE_WASM_PYMUPDF_URL=https://internal-server.example.com/wasm/pymupdf/ +> VITE_WASM_GS_URL=https://internal-server.example.com/wasm/gs/ +> VITE_WASM_CPDF_URL=https://internal-server.example.com/wasm/cpdf/ +> VITE_TESSERACT_WORKER_URL=https://internal-server.example.com/wasm/ocr/worker.min.js +> VITE_TESSERACT_CORE_URL=https://internal-server.example.com/wasm/ocr/core +> VITE_TESSERACT_LANG_URL=https://internal-server.example.com/wasm/ocr/lang-data +> VITE_OCR_FONT_BASE_URL=https://internal-server.example.com/wasm/ocr/fonts +> ``` + **Subdirectory Hosting:** BentoPDF can also be hosted from a subdirectory (e.g., `example.com/tools/bentopdf/`): @@ -414,6 +704,14 @@ docker run -p 3000:8080 bentopdf # The app will be accessible at http://localhost:3000/bentopdf/ ``` +**Default Language:** + +Set the default UI language at build time. Users can still switch languages — this only changes the initial default. Supported: `en`, `ar`, `be`, `fr`, `de`, `es`, `zh`, `zh-TW`, `vi`, `tr`, `id`, `it`, `pt`, `nl`, `da`. + +```bash +docker build --build-arg VITE_DEFAULT_LANGUAGE=fr -t bentopdf . +``` + **Combined with Simple Mode:** ```bash @@ -426,12 +724,12 @@ docker build \ docker run -p 3000:8080 bentopdf-simple ``` -> **Important**: +> [!IMPORTANT] > > - Always include trailing slashes in `BASE_URL` (e.g., `/bentopdf/` not `/bentopdf`) > - The default value is `/` for root deployment -### 🚀 Run with Docker Compose (Recommended) +### 🚀 Run with Docker Compose / Podman Compose (Recommended) For a more robust setup with auto-restart capabilities: @@ -440,7 +738,8 @@ For a more robust setup with auto-restart capabilities: ```yaml services: bentopdf: - image: bentopdf/bentopdf:latest + image: ghcr.io/alam00000/bentopdf:latest # Recommended + # image: bentopdfteam/bentopdf:latest # Alternative: Docker Hub container_name: bentopdf ports: - '3000:8080' @@ -450,11 +749,48 @@ services: 2. **Start the application**: ```bash +# Docker Compose docker-compose up -d + +# Podman Compose +podman-compose up -d ``` The application will be available at `http://localhost:3000`. +### 🐧 Podman Quadlet (Systemd Integration) + +For Linux production deployments, you can run BentoPDF as a systemd service using [Podman Quadlet](https://docs.podman.io/en/latest/markdown/podman-systemd.unit.5.html). + +Create `~/.config/containers/systemd/bentopdf.container`: + +```ini +[Unit] +Description=BentoPDF - Privacy-first PDF toolkit +After=network-online.target + +[Container] +Image=ghcr.io/alam00000/bentopdf:latest +ContainerName=bentopdf +PublishPort=3000:8080 +AutoUpdate=registry + +[Service] +Restart=always + +[Install] +WantedBy=default.target +``` + +Then enable and start: + +```bash +systemctl --user daemon-reload +systemctl --user enable --now bentopdf +``` + +For detailed Quadlet configuration, see [Self-Hosting Docker Guide](https://bentopdf.com/docs/self-hosting/docker). + ### 🏢 Simple Mode for Internal Use For organizations that want a clean, distraction-free interface focused solely on PDF tools, BentoPDF supports a **Simple Mode** that hides all branding and marketing content. @@ -468,6 +804,42 @@ For organizations that want a clean, distraction-free interface focused solely o For more details, see [SIMPLE_MODE.md](SIMPLE_MODE.md). +### 🎨 Custom Branding + +Replace the default BentoPDF logo, name, and footer text with your own. Branding is configured via environment variables at **build time** and works across all deployment methods (Docker, static hosting, air-gapped VMs). + +| Variable | Description | Default | +| ------------------ | --------------------------------------- | --------------------------------------- | +| `VITE_BRAND_NAME` | Brand name shown in header and footer | `BentoPDF` | +| `VITE_BRAND_LOGO` | Path to logo file relative to `public/` | `images/favicon-no-bg.svg` | +| `VITE_FOOTER_TEXT` | Custom footer/copyright text | `© 2026 BentoPDF. All rights reserved.` | + +**Docker:** + +```bash +docker build \ + --build-arg VITE_BRAND_NAME="AcmePDF" \ + --build-arg VITE_BRAND_LOGO="images/acme-logo.svg" \ + --build-arg VITE_FOOTER_TEXT="© 2026 Acme Corp. Internal use only." \ + -t acmepdf . +``` + +**Building from source:** + +Place your logo in the `public/` folder, then build: + +```bash +VITE_BRAND_NAME="AcmePDF" \ +VITE_BRAND_LOGO="images/acme-logo.svg" \ +VITE_FOOTER_TEXT="© 2026 Acme Corp. Internal use only." \ +npm run build +``` + +Or set the values in `.env.production` before building. + +> [!TIP] +> Branding works in both full mode and Simple Mode. You can combine it with other build-time options like `SIMPLE_MODE`, `BASE_URL`, and `VITE_DEFAULT_LANGUAGE`. + ### 🔒 Security Features BentoPDF runs as a non-root user using nginx-unprivileged for enhanced security: @@ -483,11 +855,31 @@ docker build -t bentopdf . docker run -p 8080:8080 bentopdf ``` +#### Custom User ID (PUID/PGID) + +For environments that require running as a specific non-root user (e.g., NAS devices, Kubernetes with security contexts), use the non-root Dockerfile: + +```bash +# Build the non-root image +docker build -f Dockerfile.nonroot -t bentopdf-nonroot . + +# Run with custom UID/GID +docker run -d -p 3000:8080 -e PUID=1000 -e PGID=1000 bentopdf-nonroot +``` + +| Variable | Description | Default | +| -------- | ------------------ | ------- | +| `PUID` | User ID to run as | `1000` | +| `PGID` | Group ID to run as | `1000` | + +> [!NOTE] +> The standard `Dockerfile` uses `nginx-unprivileged` (UID 101) and is recommended for most deployments. Use `Dockerfile.nonroot` only when you need a specific UID/GID. + For detailed security configuration, see [SECURITY.md](SECURITY.md). ### Digital Signature CORS Proxy (Required) -The **Digital Signature** tool uses a signing library that may need to fetch certificate chain data from certificate authority provider. Since many certificate servers don't include CORS headers, a proxy is required for this feature to work in the browser. +The **Digital Signature** tool uses a signing library that may need to fetch certificate chain data from certificate authority providers. Since many certificate servers don't include CORS headers (and often serve over HTTP, which is blocked by browsers on HTTPS sites), a proxy is required for this feature to work. **When is the proxy needed?** @@ -509,30 +901,51 @@ The **Digital Signature** tool uses a signing library that may need to fetch cer npx wrangler login ``` -3. **Deploy the worker:** +3. **Update allowed origins** — open `cors-proxy-worker.js` and change `ALLOWED_ORIGINS` to your domain: + + ```js + const ALLOWED_ORIGINS = [ + 'https://your-domain.com', + 'https://www.your-domain.com', + ]; + ``` + + > [!IMPORTANT] + > Without this step, the proxy will reject all requests from your site with a 403 error. The default only allows `bentopdf.com`. + +4. **Deploy the worker:** ```bash npx wrangler deploy ``` -4. **Note your worker URL** (e.g., `https://bentopdf-cors-proxy.your-subdomain.workers.dev`) +5. **Note your worker URL** (e.g., `https://bentopdf-cors-proxy.your-subdomain.workers.dev`) -5. **Set the environment variable when building:** +6. **Set the environment variable when building:** ```bash VITE_CORS_PROXY_URL=https://your-worker-url.workers.dev npm run build ``` + Or with Docker: + ```bash + export VITE_CORS_PROXY_URL="https://your-worker-url.workers.dev" + DOCKER_BUILDKIT=1 docker build \ + --secret id=VITE_CORS_PROXY_URL,env=VITE_CORS_PROXY_URL \ + -t your-bentopdf . + ``` #### Production Security Features The CORS proxy includes several security measures: -| Feature | Description | -| ----------------------- | ------------------------------------------------------------------------- | -| **URL Restrictions** | Only allows certificate URLs (`.crt`, `.cer`, `.pem`, `/certs/`, `/ocsp`) | -| **Private IP Blocking** | Blocks requests to localhost, 10.x, 192.168.x, 172.16-31.x | -| **File Size Limit** | Rejects files larger than 10MB | -| **Rate Limiting** | 60 requests per IP per minute (requires KV) | -| **HMAC Signatures** | Optional client-side signing (limited protection) | +| Feature | Description | +| ----------------------- | -------------------------------------------------------------------------------------- | +| **Origin Validation** | Only allows requests from domains listed in `ALLOWED_ORIGINS` | +| **URL Restrictions** | Only allows certificate URLs (`.crt`, `.cer`, `.pem`, `/certs/`, `/ocsp`, `/crl`) | +| **Private IP Blocking** | Blocks IPv4/IPv6 private ranges, link-local, loopback, decimal IPs, and cloud metadata | +| **Content-Type Safety** | Only returns safe certificate MIME types, blocks upstream content-type injection | +| **File Size Limit** | Streams response with 10MB limit, aborts mid-download if exceeded | +| **Rate Limiting** | 60 requests per IP per minute (requires KV) | +| **HMAC Signatures** | Optional client-side signing (deters casual abuse) | #### Enabling Rate Limiting (Recommended) @@ -557,7 +970,8 @@ npx wrangler deploy #### HMAC Signature Verification (Optional) -> **⚠️ Security Warning:** Client-side secrets can be extracted from bundled JavaScript. For production deployments with sensitive requirements, use your own backend server to proxy requests instead of embedding secrets in frontend code. +> [!WARNING] +> Client-side secrets can be extracted from bundled JavaScript. For production deployments with sensitive requirements, use your own backend server to proxy requests instead of embedding secrets in frontend code. BentoPDF uses client-side HMAC as a deterrent against casual abuse, but accepts this tradeoff due to its fully client-side architecture. To enable: @@ -570,24 +984,32 @@ npx wrangler secret put PROXY_SECRET # Set in build environment VITE_CORS_PROXY_SECRET=your-secret npm run build + +# Or with Docker (optional; URL secret also shown for completeness) +export VITE_CORS_PROXY_URL="https://your-worker-url.workers.dev" +export VITE_CORS_PROXY_SECRET="your-secret" +DOCKER_BUILDKIT=1 docker build \ + --secret id=VITE_CORS_PROXY_URL,env=VITE_CORS_PROXY_URL \ + --secret id=VITE_CORS_PROXY_SECRET,env=VITE_CORS_PROXY_SECRET \ + -t your-bentopdf . ``` ### 📦 Version Management -BentoPDF supports semantic versioning with multiple Docker tags available on both Docker Hub and GitHub Container Registry: +BentoPDF supports semantic versioning with multiple container tags available: -**Docker Hub:** - -- **Latest**: `bentopdf/bentopdf:latest` -- **Specific Version**: `bentopdf/bentopdf:1.0.0` -- **Version with Prefix**: `bentopdf/bentopdf:v1.0.0` - -**GitHub Container Registry:** +**GitHub Container Registry (Recommended):** - **Latest**: `ghcr.io/alam00000/bentopdf:latest` - **Specific Version**: `ghcr.io/alam00000/bentopdf:1.0.0` - **Version with Prefix**: `ghcr.io/alam00000/bentopdf:v1.0.0` +**Docker Hub:** + +- **Latest**: `bentopdfteam/bentopdf:latest` +- **Specific Version**: `bentopdfteam/bentopdf:1.0.0` +- **Version with Prefix**: `bentopdfteam/bentopdf:v1.0.0` + #### Quick Release ```bash @@ -643,7 +1065,8 @@ For detailed release instructions, see [RELEASE.md](RELEASE.md). The application will be available at `http://localhost:3000`. - > **Note:** After making any local changes to the code, rebuild the Docker image using: + > [!NOTE] + > After making any local changes to the code, rebuild the Docker image using: ```bash docker-compose -f docker-compose.dev.yml up --build -d @@ -661,7 +1084,8 @@ BentoPDF was originally built using **HTML**, **CSS**, and **vanilla JavaScript* - **TypeScript**: For type safety and an improved developer experience. - **Tailwind CSS**: For rapid and consistent UI development. -> **Note:** Some parts of the codebase still use legacy structures from the original implementation. Contributors should expect gradual updates as testing and refactoring continue. +> [!NOTE] +> Some parts of the codebase still use legacy structures from the original implementation. Contributors should expect gradual updates as testing and refactoring continue. --- @@ -724,6 +1148,8 @@ Documentation files are in the `docs/` folder: BentoPDF wouldn't be possible without the amazing open-source tools and libraries that power it. We'd like to extend our heartfelt thanks to the creators and maintainers of: +**Bundled Libraries:** + - **[PDFLib.js](https://pdf-lib.js.org/)** – For enabling powerful client-side PDF manipulation. - **[PDF.js](https://mozilla.github.io/pdf.js/)** – For the robust PDF rendering engine in the browser. - **[PDFKit](https://pdfkit.org/)** – For creating and editing PDFs with ease. @@ -731,10 +1157,16 @@ BentoPDF wouldn't be possible without the amazing open-source tools and librarie - **[Cropper.js](https://fengyuanchen.github.io/cropperjs/)** – For intuitive image cropping functionality. - **[Vite](https://vitejs.dev/)** – For lightning-fast development and build tooling. - **[Tailwind CSS](https://tailwindcss.com/)** – For rapid, flexible, and beautiful UI styling. -- **[qpdf](https://github.com/qpdf/qpdf)** and **[qpdf-wasm](https://github.com/neslinesli93/qpdf-wasm)**– A powerful command-line tool and library for inspecting, repairing, and transforming PDF file ported to wasm -- **[cpdf](https://www.coherentpdf.com/)** – For content preserving pdf operations. +- **[qpdf](https://github.com/qpdf/qpdf)** and **[qpdf-wasm](https://github.com/neslinesli93/qpdf-wasm)** – For inspecting, repairing, and transforming PDF files. - **[LibreOffice](https://www.libreoffice.org/)** – For powerful document conversion capabilities. -- **[PyMuPDF](https://github.com/pymupdf/PyMuPDF)** – For high-performance PDF manipulation and data extraction. -- **[Ghostscript(GhostPDL)](https://github.com/ArtifexSoftware/ghostpdl)** – Needs no Introduction. + +**AGPL Libraries (Pre-configured via CDN):** + +- **[CoherentPDF (cpdf)](https://www.coherentpdf.com/)** – For content-preserving PDF operations. _(AGPL-3.0)_ +- **[PyMuPDF](https://github.com/pymupdf/PyMuPDF)** – For high-performance PDF manipulation and data extraction. _(AGPL-3.0)_ +- **[Ghostscript (GhostPDL)](https://github.com/ArtifexSoftware/ghostpdl)** – For PDF/A conversion and font outlining. _(AGPL-3.0)_ + +> [!NOTE] +> AGPL-licensed libraries are not bundled in BentoPDF's source code. They are loaded at runtime from CDN (pre-configured) and can be overridden via environment variables or Advanced Settings. Your work inspires and empowers developers everywhere. Thank you for making open-source amazing! diff --git a/RELEASE.md b/RELEASE.md index 88d0c78..0aa21bc 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -38,7 +38,7 @@ npm run release:major # Major: 1.0.0 → 2.0.0 (breaking changes) **What Happens:** - ✅ Your feature commit stays as-is -- ✅ Version gets bumped in `package.json` +- ✅ Version gets bumped in `package.json` and `chart/Chart.yaml` - ✅ New release commit is created - ✅ Git tag is created (e.g., `v1.0.1`) - ✅ Everything gets pushed to GitHub @@ -215,16 +215,16 @@ git reset --hard HEAD~1 1. **GitHub Actions Triggered**: Workflow starts building Docker image 2. **Docker Build**: Multi-architecture image created 3. **Docker Push**: Images pushed to Docker Hub with tags: - - `bentopdf/bentopdf:latest` - - `bentopdf/bentopdf:1.0.1` - - `bentopdf/bentopdf:v1.0.1` + - `bentopdfteam/bentopdf:latest` + - `bentopdfteam/bentopdf:1.0.1` + - `bentopdfteam/bentopdf:v1.0.1` ### **End Result:** Users can immediately pull your new version: ```bash -docker pull bentopdf/bentopdf:1.0.1 +docker pull bentopdfteam/bentopdf:1.0.1 ``` --- diff --git a/SIMPLE_MODE.md b/SIMPLE_MODE.md index 996caef..4dd45d6 100644 --- a/SIMPLE_MODE.md +++ b/SIMPLE_MODE.md @@ -23,27 +23,35 @@ When enabled, Simple Mode will: Use the pre-built Simple Mode image directly: +**Using GitHub Container Registry (Recommended):** + +```bash +# Docker +docker run -p 3000:8080 ghcr.io/alam00000/bentopdf-simple:latest + +# Podman +podman run -p 3000:8080 ghcr.io/alam00000/bentopdf-simple:latest +``` + **Using Docker Hub:** ```bash -docker run -p 3000:8080 bentopdf/bentopdf-simple:latest +# Docker +docker run -p 3000:8080 bentopdfteam/bentopdf-simple:latest + +# Podman +podman run -p 3000:8080 docker.io/bentopdfteam/bentopdf-simple:latest ``` -**Using GitHub Container Registry:** - -```bash -docker run -p 3000:8080 ghcr.io/alam00000/bentopdf-simple:latest -``` - -Or with Docker Compose: +Or with Docker Compose / Podman Compose: ```yaml services: bentopdf: - # Using Docker Hub - image: bentopdf/bentopdf-simple:latest - # Or using GitHub Container Registry - # image: ghcr.io/alam00000/bentopdf-simple:latest + # Using GitHub Container Registry (Recommended) + image: ghcr.io/alam00000/bentopdf-simple:latest + # Or using Docker Hub + # image: bentopdfteam/bentopdf-simple:latest container_name: bentopdf restart: unless-stopped ports: @@ -105,9 +113,13 @@ This automatically builds and serves Simple Mode on `http://localhost:3000`. ### Method 2: Using Pre-built Image (Easiest for Production) ```bash -# Pull and run the Simple Mode image -docker pull bentopdf/bentopdf-simple:latest -docker run -p 3000:8080 bentopdf/bentopdf-simple:latest +# Docker - Pull and run the Simple Mode image +docker pull ghcr.io/alam00000/bentopdf-simple:latest +docker run -p 3000:8080 ghcr.io/alam00000/bentopdf-simple:latest + +# Podman +podman pull ghcr.io/alam00000/bentopdf-simple:latest +podman run -p 3000:8080 ghcr.io/alam00000/bentopdf-simple:latest ``` Open `http://localhost:3000` in your browser. @@ -127,11 +139,13 @@ Open `http://localhost:3000` in your browser. ### Method 4: Compare Both Modes ```bash -# Test Normal Mode -docker run -p 3000:8080 bentopdf/bentopdf:latest +# Test Normal Mode (Docker) +docker run -p 3000:8080 ghcr.io/alam00000/bentopdf:latest -# Test Simple Mode -docker run -p 3001:8080 bentopdf/bentopdf-simple:latest +# Test Simple Mode (Docker) +docker run -p 3001:8080 ghcr.io/alam00000/bentopdf-simple:latest + +# Podman users: replace 'docker' with 'podman' ``` - Normal Mode: `http://localhost:3000` @@ -149,52 +163,82 @@ When Simple Mode is working correctly, you should see: - ❌ No hero section with "The PDF Toolkit built for privacy" - ❌ No features, FAQ, testimonials, or footer sections -## 📦 Available Docker Images +## 📦 Available Container Images ### Normal Mode (Full Branding) -**Docker Hub:** - -- `bentopdf/bentopdf:latest` -- `bentopdf/bentopdf:v1.0.0` (versioned) - -**GitHub Container Registry:** +**GitHub Container Registry (Recommended):** - `ghcr.io/alam00000/bentopdf:latest` - `ghcr.io/alam00000/bentopdf:v1.0.0` (versioned) -### Simple Mode (Clean Interface) - **Docker Hub:** -- `bentopdf/bentopdf-simple:latest` -- `bentopdf/bentopdf-simple:v1.0.0` (versioned) +- `bentopdfteam/bentopdf:latest` +- `bentopdfteam/bentopdf:v1.0.0` (versioned) -**GitHub Container Registry:** +### Simple Mode (Clean Interface) + +**GitHub Container Registry (Recommended):** - `ghcr.io/alam00000/bentopdf-simple:latest` - `ghcr.io/alam00000/bentopdf-simple:v1.0.0` (versioned) +**Docker Hub:** + +- `bentopdfteam/bentopdf-simple:latest` +- `bentopdfteam/bentopdf-simple:v1.0.0` (versioned) + ## 🚀 Production Deployment Examples -### Internal Company Tool +### Docker Compose / Podman Compose ```yaml services: bentopdf: - image: bentopdf/bentopdf-simple:latest + image: ghcr.io/alam00000/bentopdf-simple:latest # Recommended + # image: bentopdfteam/bentopdf-simple:latest # Alternative: Docker Hub container_name: bentopdf restart: unless-stopped ports: - - '80:80' + - '80:8080' environment: - PUID=1000 - PGID=1000 ``` +### Podman Quadlet (Linux Systemd) + +Create `~/.config/containers/systemd/bentopdf-simple.container`: + +```ini +[Unit] +Description=BentoPDF Simple Mode +After=network-online.target + +[Container] +Image=ghcr.io/alam00000/bentopdf-simple:latest +ContainerName=bentopdf-simple +PublishPort=80:8080 +AutoUpdate=registry + +[Service] +Restart=always + +[Install] +WantedBy=default.target +``` + +Enable and start: + +```bash +systemctl --user daemon-reload +systemctl --user enable --now bentopdf-simple +``` + ## ⚠️ Important Notes -- **Pre-built images**: Use `bentopdf/bentopdf-simple:latest` for Simple Mode +- **Pre-built images**: Use `ghcr.io/alam00000/bentopdf-simple:latest` for Simple Mode (recommended) - **Environment variables**: `SIMPLE_MODE=true` only works during build, not runtime - **Build-time optimization**: Simple Mode uses dead code elimination for smaller bundles - **Same functionality**: All PDF tools work identically in both modes diff --git a/STATIC-HOSTING.md b/STATIC-HOSTING.md index 9cbb82b..e69800d 100644 --- a/STATIC-HOSTING.md +++ b/STATIC-HOSTING.md @@ -56,8 +56,9 @@ You can also host your own instance of BentoPDF using GitHub pages. An advantag 2. From your fork, go to `Settings->Pages`, and change the 'Source' to 'GitHub Actions' 3. Go to `Settings->Secrets and Variables > Actions`, then select 'Variables', and add the repository variable `BASE_URL`. Set the value to `/bentopdf`. *If you've renamed the repo to something other than bentopdf, put that here*. 4. Go to `Actions` in the top menu, and select 'I understand' to enable Actions -5. Within Actions, on the left, select 'Deploy static content to Pages', and then on the right select 'Run workflow', and in the dropdown, 'Run Workflow'. The action will not run to build BentoPDF and deploy it to GitHub Pages. +5. Within Actions, on the left, select 'Deploy static content to Pages', and then on the right select 'Run workflow', and in the dropdown, 'Run Workflow'. The action will now run to build BentoPDF and deploy it to GitHub Pages. When the build completes, you can find the website at `https://[your-github-username]/bentopdf` -If/when you merge changes from the source BentoPDF repository, the build and deploy action will automatically be kicked off and the new version will be automatically deployed to GitHub Pages. \ No newline at end of file + +If/when you merge changes from the source BentoPDF repository, the build and deploy action will automatically be kicked off and the new version will be automatically deployed to GitHub Pages. diff --git a/TRANSLATION.md b/TRANSLATION.md index bfb3d46..26323f6 100644 --- a/TRANSLATION.md +++ b/TRANSLATION.md @@ -20,6 +20,7 @@ This guide will help you add new languages or improve existing translations for BentoPDF uses **i18next** for internationalization (i18n). Currently supported languages: - **English** (`en`) - Default +- **Belarusian** (`be`) - **German** (`de`) - **Spanish** (`es`) - **French** (`fr`) @@ -30,6 +31,7 @@ BentoPDF uses **i18next** for internationalization (i18n). Currently supported l - **Indonesian** (`id`) - **Chinese** (`zh`) - **Traditional Chinese (Taiwan)** (`zh-TW`) +- **Korean** (`ko`) The app automatically detects the language from the URL path: @@ -117,7 +119,7 @@ Open `public/locales/es/common.json` and translate all the values: "inicio": "Inicio" ``` -Then do the same for `public/locales/fr/tools.json` to translate all tool names and descriptions. +Then do the same for `public/locales/es/tools.json` to translate all tool names and descriptions. ### Step 3: Register the Language @@ -598,6 +600,7 @@ Current translation coverage: | Indonesian | `id` | ✅ Complete | Community | | Chinese | `zh` | ✅ Complete | Community | | Traditional Chinese | `zh-TW` | ✅ Complete | Community | +| Korean | `ko` | ✅ Complete | Community | | Your Language | `??` | 🚧 In Progress | You? | --- diff --git a/chart/.helmignore b/chart/.helmignore new file mode 100644 index 0000000..0e8a0eb --- /dev/null +++ b/chart/.helmignore @@ -0,0 +1,23 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*.orig +*~ +# Various IDEs +.project +.idea/ +*.tmproj +.vscode/ diff --git a/chart/Chart.yaml b/chart/Chart.yaml new file mode 100644 index 0000000..a8192a5 --- /dev/null +++ b/chart/Chart.yaml @@ -0,0 +1,16 @@ +apiVersion: v2 +name: bentopdf +description: BentoPDF static frontend served by NGINX +icon: https://raw.githubusercontent.com/spwoodcock/bentopdf/refs/heads/main/public/favicon.ico +type: application + +# This is the chart version. This version number should be incremented each time you make changes +# to the chart and its templates, including the app version. +# Versions are expected to follow Semantic Versioning (https://semver.org/) +version: 1.0.2 + +# This is the version number of the application being deployed. This version number should be +# incremented each time you make changes to the application. Versions are not expected to +# follow Semantic Versioning. They should reflect the version the application is using. +# It is recommended to use it with quotes. +appVersion: '2.2.1' diff --git a/chart/README.md b/chart/README.md new file mode 100644 index 0000000..18b6367 --- /dev/null +++ b/chart/README.md @@ -0,0 +1,100 @@ +# BentoPDF Helm Chart + +Deploys **BentoPDF** as a **single NGINX container** serving the static frontend. + +## Quickstart + +### Option 1: Port-forward (testing) + +```bash +helm install bentopdf ./chart +kubectl port-forward deploy/bentopdf 8080:8080 +# open http://127.0.0.1:8080 +``` + +### Option 2: Ingress + +```yaml +ingress: + enabled: true + className: nginx + hosts: + - host: bentopdf.example.com + paths: + - path: / + pathType: Prefix +``` + +### Option 3: Gateway API (Gateway + HTTPRoute) + +```yaml +gateway: + enabled: true + gatewayClassName: 'cloudflare' # or your gateway class + +httpRoute: + enabled: true + hostnames: + - pdfs.example.com +``` + +**Note:** Both Gateway and HTTPRoute default to the release namespace. Omit `namespace` fields to use the release namespace automatically. + +If you have an existing Gateway, set `gateway.enabled=false` and configure `httpRoute.parentRefs`: + +```yaml +gateway: + enabled: false + +httpRoute: + enabled: true + parentRefs: + - name: existing-gateway + namespace: gateway-namespace + sectionName: http + hostnames: + - pdfs.example.com +``` + +## Configuration + +### Image + +- **`image.repository`**: container image repo (default: `ghcr.io/alam00000/bentopdf-simple`) +- **`image.tag`**: image tag (default: `Chart.appVersion`) +- **`image.pullPolicy`**: default `IfNotPresent` + +### Ports + +- **`containerPort`**: container listen port (**8080** for the BentoPDF nginx image) +- **`service.port`**: Service port exposed in-cluster (default **80**) + +### Environment Variables + +```yaml +env: + - name: DISABLE_IPV6 + value: 'true' +``` + +## Publish this chart to GHCR (OCI) for testing/deploying + +### Build And Push OCI + +```bash +echo "$GHCR_TOKEN" | helm registry login ghcr.io -u "$GHCR_USERNAME" --password-stdin + +cd chart +helm package . + +# produces bentopdf-.tgz +helm push bentopdf-*.tgz oci://ghcr.io/$GHCR_USERNAME/charts +``` + +This could be automated as part of a Github workflow. + +### Deploy + +```bash +helm upgrade --install bentopdf oci://ghcr.io/$GHCR_USERNAME/charts/bentopdf --version 1.0.0 +``` diff --git a/chart/templates/NOTES.txt b/chart/templates/NOTES.txt new file mode 100644 index 0000000..fc3864e --- /dev/null +++ b/chart/templates/NOTES.txt @@ -0,0 +1,47 @@ +1. Get the application URL by running these commands: +{{- if .Values.httpRoute.enabled }} +{{- $defaultGatewayName := .Values.gateway.name -}} +{{- if not $defaultGatewayName -}} +{{- $defaultGatewayName = printf "%s-gateway" (include "bentopdf.name" .) -}} +{{- end -}} +{{- $defaultGatewayNs := .Release.Namespace -}} +{{- if .Values.gateway.enabled -}} +{{- $defaultGatewayNs = default .Release.Namespace .Values.gateway.namespace -}} +{{- end -}} +{{- $gatewayName := $defaultGatewayName -}} +{{- $gatewayNs := $defaultGatewayNs -}} +{{- if and .Values.httpRoute.parentRefs (first .Values.httpRoute.parentRefs) -}} +{{- $firstRef := first .Values.httpRoute.parentRefs -}} +{{- $gatewayName = default $defaultGatewayName $firstRef.name -}} +{{- $gatewayNs = default $defaultGatewayNs $firstRef.namespace -}} +{{- end -}} +{{- if .Values.httpRoute.hostnames }} + export APP_HOSTNAME={{ .Values.httpRoute.hostnames | first }} + echo "Visit http://$APP_HOSTNAME to use your application" +{{- else }} + echo "HTTPRoute is enabled. Configure httpRoute.hostnames to see your URL here." +{{- end }} + NOTE: Your HTTPRoute depends on the listener configuration of your gateway and your HTTPRoute rules. + The rules can be set for path, method, header and query parameters. + You can check the gateway configuration with 'kubectl get --namespace {{ $gatewayNs }} gateway/{{ $gatewayName }} -o yaml' +{{- else if .Values.ingress.enabled }} +{{- range $host := .Values.ingress.hosts }} + {{- range .paths }} + http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }}{{ .path }} + {{- end }} +{{- end }} +{{- else if contains "NodePort" .Values.service.type }} + export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "bentopdf.name" . }}) + export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}") + echo http://$NODE_IP:$NODE_PORT +{{- else if contains "LoadBalancer" .Values.service.type }} + NOTE: It may take a few minutes for the LoadBalancer IP to be available. + You can watch its status by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "bentopdf.name" . }}' + export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "bentopdf.name" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}") + echo http://$SERVICE_IP:{{ .Values.service.port }} +{{- else if contains "ClusterIP" .Values.service.type }} + export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "bentopdf.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}") + export CONTAINER_PORT=$(kubectl get pod --namespace {{ .Release.Namespace }} $POD_NAME -o jsonpath="{.spec.containers[0].ports[0].containerPort}") + echo "Visit http://127.0.0.1:8080 to use your application" + kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8080:$CONTAINER_PORT +{{- end }} diff --git a/chart/templates/_helpers.tpl b/chart/templates/_helpers.tpl new file mode 100644 index 0000000..ba10597 --- /dev/null +++ b/chart/templates/_helpers.tpl @@ -0,0 +1,33 @@ +{{/* +Expand the name of the bentopdf +*/}} +{{- define "bentopdf.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "bentopdf.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define "bentopdf.labels" -}} +helm.sh/chart: {{ include "bentopdf.chart" . }} +{{ include "bentopdf.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Selector labels +*/}} +{{- define "bentopdf.selectorLabels" -}} +app.kubernetes.io/name: {{ include "bentopdf.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} diff --git a/chart/templates/deployment.yaml b/chart/templates/deployment.yaml new file mode 100644 index 0000000..737e68f --- /dev/null +++ b/chart/templates/deployment.yaml @@ -0,0 +1,52 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "bentopdf.name" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "bentopdf.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.replicaCount }} + selector: + matchLabels: + {{- include "bentopdf.selectorLabels" . | nindent 6 }} + template: + metadata: + {{- with .Values.podAnnotations }} + annotations: + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + {{- include "bentopdf.labels" . | nindent 8 }} + {{- with .Values.podLabels }} + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + containers: + - name: {{ .Chart.Name }} + image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - name: http + containerPort: {{ .Values.containerPort }} + protocol: TCP + {{- with .Values.env }} + env: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.livenessProbe }} + livenessProbe: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.readinessProbe }} + readinessProbe: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.resources }} + resources: + {{- toYaml . | nindent 12 }} + {{- end }} diff --git a/chart/templates/gateway.yaml b/chart/templates/gateway.yaml new file mode 100644 index 0000000..9d0d6e9 --- /dev/null +++ b/chart/templates/gateway.yaml @@ -0,0 +1,22 @@ +{{- if .Values.gateway.enabled -}} +{{- $gatewayName := .Values.gateway.name -}} +{{- if not $gatewayName -}} +{{- $gatewayName = printf "%s-gateway" (include "bentopdf.name" .) -}} +{{- end -}} +{{- $gatewayNs := default .Release.Namespace .Values.gateway.namespace -}} +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: + name: {{ $gatewayName }} + namespace: {{ $gatewayNs }} + labels: + {{- include "bentopdf.labels" . | nindent 4 }} + {{- with .Values.gateway.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + gatewayClassName: {{ required "values.gateway.gatewayClassName is required when gateway.enabled=true" .Values.gateway.gatewayClassName }} + listeners: + {{- toYaml .Values.gateway.listeners | nindent 4 }} +{{- end }} diff --git a/chart/templates/httproute.yaml b/chart/templates/httproute.yaml new file mode 100644 index 0000000..70e033a --- /dev/null +++ b/chart/templates/httproute.yaml @@ -0,0 +1,52 @@ +{{- if .Values.httpRoute.enabled -}} +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: {{ include "bentopdf.name" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "bentopdf.labels" . | nindent 4 }} + {{- with .Values.httpRoute.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + parentRefs: + {{- if .Values.httpRoute.parentRefs }} + {{- range $ref := .Values.httpRoute.parentRefs }} + - name: {{ $.Values.gateway.name | default $ref.name | quote }} + namespace: {{ $ref.namespace | default (default $.Release.Namespace $.Values.gateway.namespace) | quote }} + sectionName: {{ $ref.sectionName | default "http" | quote }} + {{- with $ref.kind }} + kind: {{ . | quote }} + {{- end }} + {{- with $ref.group }} + group: {{ . | quote }} + {{- end }} + {{- end }} + {{- else }} + # Default parentRef when parentRefs is empty or not set + - name: {{ .Values.gateway.name | default (printf "%s-gateway" (include "bentopdf.name" $)) | quote }} + namespace: {{ .Values.gateway.namespace | default .Release.Namespace | quote }} + sectionName: "http" + {{- end }} + {{- with .Values.httpRoute.hostnames }} + hostnames: + {{- toYaml . | nindent 4 }} + {{- end }} + rules: + {{- range .Values.httpRoute.rules }} + - {{- with .matches }} + matches: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .filters }} + filters: + {{- toYaml . | nindent 8 }} + {{- end }} + backendRefs: + - name: {{ include "bentopdf.name" $ }} + port: {{ $.Values.service.port }} + weight: 1 + {{- end }} +{{- end }} diff --git a/chart/templates/ingress.yaml b/chart/templates/ingress.yaml new file mode 100644 index 0000000..04b8240 --- /dev/null +++ b/chart/templates/ingress.yaml @@ -0,0 +1,44 @@ +{{- if .Values.ingress.enabled -}} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ include "bentopdf.name" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "bentopdf.labels" . | nindent 4 }} + {{- with .Values.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- with .Values.ingress.className }} + ingressClassName: {{ . }} + {{- end }} + {{- if .Values.ingress.tls }} + tls: + {{- range .Values.ingress.tls }} + - hosts: + {{- range .hosts }} + - {{ . | quote }} + {{- end }} + secretName: {{ .secretName }} + {{- end }} + {{- end }} + rules: + {{- range .Values.ingress.hosts }} + - host: {{ .host | quote }} + http: + paths: + {{- range .paths }} + - path: {{ .path }} + {{- with .pathType }} + pathType: {{ . }} + {{- end }} + backend: + service: + name: {{ include "bentopdf.name" $ }} + port: + number: {{ $.Values.service.port }} + {{- end }} + {{- end }} +{{- end }} diff --git a/chart/templates/service.yaml b/chart/templates/service.yaml new file mode 100644 index 0000000..04c36d0 --- /dev/null +++ b/chart/templates/service.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "bentopdf.name" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "bentopdf.labels" . | nindent 4 }} +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.port }} + targetPort: http + protocol: TCP + name: http + selector: + {{- include "bentopdf.selectorLabels" . | nindent 4 }} diff --git a/chart/templates/tests/test-connection.yaml b/chart/templates/tests/test-connection.yaml new file mode 100644 index 0000000..474132a --- /dev/null +++ b/chart/templates/tests/test-connection.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Pod +metadata: + name: "{{ include "bentopdf.name" . }}-test-connection" + labels: + {{- include "bentopdf.labels" . | nindent 4 }} + annotations: + "helm.sh/hook": test +spec: + containers: + - name: wget + image: busybox + command: ['wget'] + args: ['-qO-', 'http://{{ include "bentopdf.name" . }}:{{ .Values.service.port }}/'] + restartPolicy: Never diff --git a/chart/values.yaml b/chart/values.yaml new file mode 100644 index 0000000..91ea545 --- /dev/null +++ b/chart/values.yaml @@ -0,0 +1,75 @@ +# Default values for the BentoPDF chart (single nginx static frontend). +replicaCount: 1 + +image: + # Image built from this repo's `Dockerfile` (nginx serving static frontend). + repository: ghcr.io/alam00000/bentopdf-simple + pullPolicy: IfNotPresent + tag: '' + +imagePullSecrets: [] +nameOverride: '' + +podAnnotations: {} +podLabels: {} + +service: + type: ClusterIP + port: 80 + +# Container listen port (BentoPDF nginx image listens on 8080). +containerPort: 8080 + +env: [] + +ingress: + enabled: false + className: '' + annotations: {} + hosts: + - host: bentopdf.local + paths: + - path: / + pathType: ImplementationSpecific + tls: [] + +# Gateway API (optional) +gateway: + enabled: false + name: '' # default: release name + "-gateway" suffix + namespace: '' # to override the namespace + annotations: {} + gatewayClassName: '' # required when enabled=true + listeners: + - name: http + protocol: HTTP + port: 80 + allowedRoutes: + namespaces: + from: Same # Allow routes from the same namespace + +httpRoute: + enabled: false + annotations: {} + # parentRefs: # Omit entirely to use default (auto-references gateway in same namespace) + # - name: "" # default: gateway.name or {release-name}-gateway + # namespace: "" # to override the namespace + # sectionName: "http" + hostnames: + - bentopdf.local + rules: + - matches: + - path: + type: PathPrefix + value: / + +resources: {} + +livenessProbe: + httpGet: + path: / + port: http +readinessProbe: + httpGet: + path: / + port: http diff --git a/cloudflare/WASM-PROXY.md b/cloudflare/WASM-PROXY.md new file mode 100644 index 0000000..fa6963d --- /dev/null +++ b/cloudflare/WASM-PROXY.md @@ -0,0 +1,104 @@ +# WASM Proxy Setup Guide + +BentoPDF uses a Cloudflare Worker to proxy WASM library requests, bypassing CORS restrictions when loading AGPL-licensed components (PyMuPDF, Ghostscript, CoherentPDF) from external sources. + +## Quick Start + +### 1. Deploy the Worker + +```bash +cd cloudflare +npx wrangler login +npx wrangler deploy -c wasm-wrangler.toml +``` + +### 2. Configure Source URLs + +Set environment secrets with the base URLs for your WASM files: + +```bash +# Option A: Interactive prompts +npx wrangler secret put PYMUPDF_SOURCE -c wasm-wrangler.toml +npx wrangler secret put GS_SOURCE -c wasm-wrangler.toml +npx wrangler secret put CPDF_SOURCE -c wasm-wrangler.toml + +# Option B: Set via Cloudflare Dashboard +# Go to Workers & Pages > bentopdf-wasm-proxy > Settings > Variables +``` + +**Recommended Source URLs:** + +- PYMUPDF_SOURCE: `https://cdn.jsdelivr.net/npm/@bentopdf/pymupdf-wasm@0.11.16/` +- GS_SOURCE: `https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm/assets/` +- CPDF_SOURCE: `https://cdn.jsdelivr.net/npm/coherentpdf/dist/` + +> **Note:** You can use your own hosted WASM files instead of the recommended URLs. Just ensure your files match the expected directory structure and file names that BentoPDF expects for each module. + +### 3. Configure BentoPDF + +**Option A: Environment variables (recommended — zero-config for users)** + +Set these in `.env.production` or pass as Docker build args: + +```bash +VITE_WASM_PYMUPDF_URL=https://bentopdf-wasm-proxy..workers.dev/pymupdf/ +VITE_WASM_GS_URL=https://bentopdf-wasm-proxy..workers.dev/gs/ +VITE_WASM_CPDF_URL=https://bentopdf-wasm-proxy..workers.dev/cpdf/ +``` + +**Option B: Manual per-user configuration** + +In BentoPDF's Advanced Settings (wasm-settings.html), enter: + +| Module | URL | +| ----------- | ------------------------------------------------------------------- | +| PyMuPDF | `https://bentopdf-wasm-proxy..workers.dev/pymupdf/` | +| Ghostscript | `https://bentopdf-wasm-proxy..workers.dev/gs/` | +| CoherentPDF | `https://bentopdf-wasm-proxy..workers.dev/cpdf/` | + +## Custom Domain (Optional) + +To use a custom domain like `wasm.bentopdf.com`: + +1. Add route in `wasm-wrangler.toml`: + +```toml +routes = [ + { pattern = "wasm.bentopdf.com/*", zone_name = "bentopdf.com" } +] +``` + +2. Add DNS record in Cloudflare: + - Type: AAAA + - Name: wasm + - Content: 100:: + - Proxied: Yes + +3. Redeploy: + +```bash +npx wrangler deploy -c wasm-wrangler.toml +``` + +## Security Features + +- **Origin validation**: Only allows requests from configured origins +- **Rate limiting**: 100 requests/minute per IP (requires KV namespace) +- **File type restrictions**: Only WASM-related files (.js, .wasm, .data, etc.) +- **Size limits**: Max 100MB per file +- **Caching**: Reduces origin requests and improves performance + +## Self-Hosting Notes + +1. Update `ALLOWED_ORIGINS` in `wasm-proxy-worker.js` to include your domain +2. Host your WASM files on any origin (R2, S3, or any CDN) +3. Set source URLs as secrets in your worker + +## Endpoints + +| Endpoint | Description | +| ------------ | -------------------------------------- | +| `/` | Health check, shows configured modules | +| `/pymupdf/*` | PyMuPDF WASM files | +| `/gs/*` | Ghostscript WASM files | +| `/cpdf/*` | CoherentPDF files | diff --git a/cloudflare/cors-proxy-worker.js b/cloudflare/cors-proxy-worker.js index b9ba458..0d3e105 100644 --- a/cloudflare/cors-proxy-worker.js +++ b/cloudflare/cors-proxy-worker.js @@ -1,39 +1,38 @@ /** * BentoPDF CORS Proxy Worker - * + * * This Cloudflare Worker proxies certificate requests for the digital signing tool. * It fetches certificates from external CAs that don't have CORS headers enabled * and returns them with proper CORS headers. - * - * + * + * * Deploy: npx wrangler deploy - * + * * Required Environment Variables (set in wrangler.toml or Cloudflare dashboard): * - PROXY_SECRET: Shared secret for HMAC signature verification */ -const ALLOWED_PATTERNS = [ - /\.crt$/i, - /\.cer$/i, - /\.pem$/i, - /\/certs\//i, - /\/ocsp/i, - /\/crl/i, - /caIssuers/i, +const ALLOWED_PATH_PATTERNS = [ + /\.crt$/i, + /\.cer$/i, + /\.pem$/i, + /\/certs\//i, + /\/ocsp/i, + /\/crl/i, + /caIssuers/i, ]; -const ALLOWED_ORIGINS = [ - 'https://www.bentopdf.com', - 'https://bentopdf.com', -]; +const ALLOWED_ORIGINS = ['https://www.bentopdf.com', 'https://bentopdf.com']; -const BLOCKED_DOMAINS = [ - 'localhost', - '127.0.0.1', - '0.0.0.0', +const SAFE_CONTENT_TYPES = [ + 'application/x-x509-ca-cert', + 'application/pkix-cert', + 'application/x-pem-file', + 'application/pkcs7-mime', + 'application/octet-stream', + 'text/plain', ]; - const MAX_TIMESTAMP_AGE_MS = 5 * 60 * 1000; const RATE_LIMIT_MAX_REQUESTS = 60; @@ -42,310 +41,415 @@ const RATE_LIMIT_WINDOW_MS = 60 * 1000; const MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024; async function verifySignature(message, signature, secret) { - try { - const encoder = new TextEncoder(); - const key = await crypto.subtle.importKey( - 'raw', - encoder.encode(secret), - { name: 'HMAC', hash: 'SHA-256' }, - false, - ['verify'] - ); - - const signatureBytes = new Uint8Array( - signature.match(/.{1,2}/g).map(byte => parseInt(byte, 16)) - ); - - return await crypto.subtle.verify( - 'HMAC', - key, - signatureBytes, - encoder.encode(message) - ); - } catch (e) { - console.error('Signature verification error:', e); - return false; - } -} - -async function generateSignature(message, secret) { + try { const encoder = new TextEncoder(); const key = await crypto.subtle.importKey( - 'raw', - encoder.encode(secret), - { name: 'HMAC', hash: 'SHA-256' }, - false, - ['sign'] + 'raw', + encoder.encode(secret), + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['verify'] ); - const signature = await crypto.subtle.sign( - 'HMAC', - key, - encoder.encode(message) + const signatureBytes = new Uint8Array( + signature.match(/.{1,2}/g).map((byte) => parseInt(byte, 16)) ); - return Array.from(new Uint8Array(signature)) - .map(b => b.toString(16).padStart(2, '0')) - .join(''); + return await crypto.subtle.verify( + 'HMAC', + key, + signatureBytes, + encoder.encode(message) + ); + } catch (e) { + console.error('Signature verification error:', e); + return false; + } } function isAllowedOrigin(origin) { - if (!origin) return false; - return ALLOWED_ORIGINS.some(allowed => origin.startsWith(allowed.replace(/\/$/, ''))); + if (!origin) return false; + return ALLOWED_ORIGINS.includes(origin); +} + +function isPrivateOrReservedHost(hostname) { + if ( + /^10\./.test(hostname) || + /^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(hostname) || + /^192\.168\./.test(hostname) || + /^169\.254\./.test(hostname) || // link-local (cloud metadata) + /^100\.(6[4-9]|[7-9]\d|1[0-1]\d|12[0-7])\./.test(hostname) || // CGNAT + /^127\./.test(hostname) || + /^0\./.test(hostname) + ) { + return true; + } + + if (/^\d+$/.test(hostname)) { + return true; + } + + const lower = hostname.toLowerCase().replace(/^\[|\]$/g, ''); + if ( + lower === '::1' || + lower.startsWith('::ffff:') || + lower.startsWith('fe80') || + lower.startsWith('fc') || + lower.startsWith('fd') || + lower.startsWith('ff') + ) { + return true; + } + + const blockedNames = [ + 'localhost', + 'localhost.localdomain', + '0.0.0.0', + '[::1]', + ]; + if (blockedNames.includes(hostname.toLowerCase())) { + return true; + } + + return false; } function isValidCertificateUrl(urlString) { - try { - const url = new URL(urlString); + try { + const url = new URL(urlString); - if (!['http:', 'https:'].includes(url.protocol)) { - return false; - } - - if (BLOCKED_DOMAINS.some(domain => url.hostname.includes(domain))) { - return false; - } - - const hostname = url.hostname; - if (/^10\./.test(hostname) || - /^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(hostname) || - /^192\.168\./.test(hostname)) { - return false; - } - - return ALLOWED_PATTERNS.some(pattern => pattern.test(urlString)); - } catch { - return false; + if (!['http:', 'https:'].includes(url.protocol)) { + return false; } + + if (isPrivateOrReservedHost(url.hostname)) { + return false; + } + + return ALLOWED_PATH_PATTERNS.some((pattern) => pattern.test(url.pathname)); + } catch { + return false; + } +} + +function getSafeContentType(upstreamContentType) { + if (!upstreamContentType) return 'application/octet-stream'; + const match = SAFE_CONTENT_TYPES.find((ct) => + upstreamContentType.startsWith(ct) + ); + return match || 'application/octet-stream'; } function corsHeaders(origin) { - return { - 'Access-Control-Allow-Origin': origin || '*', - 'Access-Control-Allow-Methods': 'GET, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type', - 'Access-Control-Max-Age': '86400', - }; + return { + 'Access-Control-Allow-Origin': origin, + 'Access-Control-Allow-Methods': 'GET, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type', + 'Access-Control-Max-Age': '86400', + }; } function handleOptions(request) { - const origin = request.headers.get('Origin'); - return new Response(null, { - status: 204, - headers: corsHeaders(origin), - }); + const origin = request.headers.get('Origin'); + if (!isAllowedOrigin(origin)) { + return new Response(null, { status: 403 }); + } + return new Response(null, { + status: 204, + headers: corsHeaders(origin), + }); } export default { - async fetch(request, env, ctx) { - const url = new URL(request.url); - const origin = request.headers.get('Origin'); + async fetch(request, env, ctx) { + const url = new URL(request.url); + const origin = request.headers.get('Origin'); - if (request.method === 'OPTIONS') { - return handleOptions(request); + if (request.method === 'OPTIONS') { + return handleOptions(request); + } + + // NOTE: If you are selfhosting this proxy, you can remove this check, or can set it to only accept requests from your own domain + if (!isAllowedOrigin(origin)) { + return new Response( + JSON.stringify({ + error: 'Forbidden', + message: 'This proxy only accepts requests from allowed origins', + }), + { + status: 403, + headers: { + 'Content-Type': 'application/json', + }, } + ); + } - // NOTE: If you are selfhosting this proxy, you can remove this check, or can set it to only accept requests from your own domain - if (!isAllowedOrigin(origin)) { - return new Response(JSON.stringify({ - error: 'Forbidden', - message: 'This proxy only accepts requests from bentopdf.com', - }), { - status: 403, - headers: { - 'Content-Type': 'application/json', - }, - }); + if (request.method !== 'GET') { + return new Response('Method not allowed', { + status: 405, + headers: corsHeaders(origin), + }); + } + + const targetUrl = url.searchParams.get('url'); + + if (!targetUrl) { + return new Response( + JSON.stringify({ + error: 'Missing url parameter', + usage: 'GET /?url=', + }), + { + status: 400, + headers: { + ...corsHeaders(origin), + 'Content-Type': 'application/json', + }, } + ); + } - if (request.method !== 'GET') { - return new Response('Method not allowed', { - status: 405, - headers: corsHeaders(origin), - }); + if (!isValidCertificateUrl(targetUrl)) { + return new Response( + JSON.stringify({ + error: 'Invalid or disallowed URL', + message: + 'Only certificate-related URLs are allowed (*.crt, *.cer, *.pem, /certs/, /ocsp, /crl)', + }), + { + status: 403, + headers: { + ...corsHeaders(origin), + 'Content-Type': 'application/json', + }, } + ); + } - const targetUrl = url.searchParams.get('url'); - const timestamp = url.searchParams.get('t'); - const signature = url.searchParams.get('sig'); + if (env.PROXY_SECRET) { + const timestamp = url.searchParams.get('t'); + const signature = url.searchParams.get('sig'); - if (env.PROXY_SECRET) { - if (!timestamp || !signature) { - return new Response(JSON.stringify({ - error: 'Missing authentication parameters', - message: 'Request must include timestamp (t) and signature (sig) parameters', - }), { - status: 401, - headers: { - ...corsHeaders(origin), - 'Content-Type': 'application/json', - }, - }); - } - - const requestTime = parseInt(timestamp, 10); - const now = Date.now(); - if (isNaN(requestTime) || Math.abs(now - requestTime) > MAX_TIMESTAMP_AGE_MS) { - return new Response(JSON.stringify({ - error: 'Request expired or invalid timestamp', - message: 'Timestamp must be within 5 minutes of current time', - }), { - status: 401, - headers: { - ...corsHeaders(origin), - 'Content-Type': 'application/json', - }, - }); - } - - const message = `${targetUrl}${timestamp}`; - const isValid = await verifySignature(message, signature, env.PROXY_SECRET); - - if (!isValid) { - return new Response(JSON.stringify({ - error: 'Invalid signature', - message: 'Request signature verification failed', - }), { - status: 401, - headers: { - ...corsHeaders(origin), - 'Content-Type': 'application/json', - }, - }); + if (!timestamp || !signature) { + return new Response( + JSON.stringify({ + error: 'Missing authentication parameters', + message: + 'Request must include timestamp (t) and signature (sig) parameters', + }), + { + status: 401, + headers: { + ...corsHeaders(origin), + 'Content-Type': 'application/json', + }, + } + ); + } + + const requestTime = parseInt(timestamp, 10); + const now = Date.now(); + if ( + isNaN(requestTime) || + Math.abs(now - requestTime) > MAX_TIMESTAMP_AGE_MS + ) { + return new Response( + JSON.stringify({ + error: 'Request expired or invalid timestamp', + message: 'Timestamp must be within 5 minutes of current time', + }), + { + status: 401, + headers: { + ...corsHeaders(origin), + 'Content-Type': 'application/json', + }, + } + ); + } + + const message = `${targetUrl}${timestamp}`; + const isValid = await verifySignature( + message, + signature, + env.PROXY_SECRET + ); + + if (!isValid) { + return new Response( + JSON.stringify({ + error: 'Invalid signature', + message: 'Request signature verification failed', + }), + { + status: 401, + headers: { + ...corsHeaders(origin), + 'Content-Type': 'application/json', + }, + } + ); + } + } + + const clientIP = request.headers.get('CF-Connecting-IP') || 'unknown'; + const rateLimitKey = `ratelimit:${clientIP}`; + const now = Date.now(); + + if (env.RATE_LIMIT_KV) { + const rateLimitData = await env.RATE_LIMIT_KV.get(rateLimitKey, { + type: 'json', + }); + const requests = rateLimitData?.requests || []; + + const recentRequests = requests.filter( + (t) => now - t < RATE_LIMIT_WINDOW_MS + ); + + if (recentRequests.length >= RATE_LIMIT_MAX_REQUESTS) { + return new Response( + JSON.stringify({ + error: 'Rate limit exceeded', + message: `Maximum ${RATE_LIMIT_MAX_REQUESTS} requests per minute. Please try again later.`, + retryAfter: Math.ceil( + (recentRequests[0] + RATE_LIMIT_WINDOW_MS - now) / 1000 + ), + }), + { + status: 429, + headers: { + ...corsHeaders(origin), + 'Content-Type': 'application/json', + 'Retry-After': Math.ceil( + (recentRequests[0] + RATE_LIMIT_WINDOW_MS - now) / 1000 + ).toString(), + }, + } + ); + } + + recentRequests.push(now); + await env.RATE_LIMIT_KV.put( + rateLimitKey, + JSON.stringify({ requests: recentRequests }), + { + expirationTtl: 120, + } + ); + } else { + console.warn( + '[CORS Proxy] RATE_LIMIT_KV not configured — rate limiting is disabled' + ); + } + + try { + const response = await fetch(targetUrl, { + headers: { + 'User-Agent': 'BentoPDF-CertProxy/1.0', + }, + }); + + if (!response.ok) { + return new Response( + JSON.stringify({ + error: 'Failed to fetch certificate', + status: response.status, + }), + { + status: response.status, + headers: { + ...corsHeaders(origin), + 'Content-Type': 'application/json', + }, + } + ); + } + + // Check Content-Length header first (fast reject for known-large responses) + const contentLength = parseInt( + response.headers.get('Content-Length') || '0', + 10 + ); + if (contentLength > MAX_FILE_SIZE_BYTES) { + return new Response( + JSON.stringify({ + error: 'File too large', + message: `Certificate file exceeds maximum size of ${MAX_FILE_SIZE_BYTES / 1024}KB`, + }), + { + status: 413, + headers: { + ...corsHeaders(origin), + 'Content-Type': 'application/json', + }, + } + ); + } + + const reader = response.body.getReader(); + const chunks = []; + let totalSize = 0; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + totalSize += value.byteLength; + if (totalSize > MAX_FILE_SIZE_BYTES) { + reader.cancel(); + return new Response( + JSON.stringify({ + error: 'File too large', + message: `Certificate file exceeds maximum size of ${MAX_FILE_SIZE_BYTES / 1024}KB`, + }), + { + status: 413, + headers: { + ...corsHeaders(origin), + 'Content-Type': 'application/json', + }, } + ); } - if (!targetUrl) { - return new Response(JSON.stringify({ - error: 'Missing url parameter', - usage: 'GET /?url=', - }), { - status: 400, - headers: { - ...corsHeaders(origin), - 'Content-Type': 'application/json', - }, - }); + chunks.push(value); + } + + const certData = new Uint8Array(totalSize); + let offset = 0; + for (const chunk of chunks) { + certData.set(chunk, offset); + offset += chunk.byteLength; + } + + return new Response(certData, { + status: 200, + headers: { + ...corsHeaders(origin), + 'Content-Type': getSafeContentType( + response.headers.get('Content-Type') + ), + 'Content-Length': totalSize.toString(), + 'Cache-Control': 'public, max-age=86400', + 'X-Content-Type-Options': 'nosniff', + }, + }); + } catch (error) { + console.error('Proxy fetch error:', error); + return new Response( + JSON.stringify({ + error: 'Proxy error', + message: 'Failed to fetch the requested certificate', + }), + { + status: 500, + headers: { + ...corsHeaders(origin), + 'Content-Type': 'application/json', + }, } - - if (!isValidCertificateUrl(targetUrl)) { - return new Response(JSON.stringify({ - error: 'Invalid or disallowed URL', - message: 'Only certificate-related URLs are allowed (*.crt, *.cer, *.pem, /certs/, /ocsp, /crl)', - }), { - status: 403, - headers: { - ...corsHeaders(origin), - 'Content-Type': 'application/json', - }, - }); - } - - const clientIP = request.headers.get('CF-Connecting-IP') || 'unknown'; - const rateLimitKey = `ratelimit:${clientIP}`; - const now = Date.now(); - - if (env.RATE_LIMIT_KV) { - const rateLimitData = await env.RATE_LIMIT_KV.get(rateLimitKey, { type: 'json' }); - const requests = rateLimitData?.requests || []; - - const recentRequests = requests.filter(t => now - t < RATE_LIMIT_WINDOW_MS); - - if (recentRequests.length >= RATE_LIMIT_MAX_REQUESTS) { - return new Response(JSON.stringify({ - error: 'Rate limit exceeded', - message: `Maximum ${RATE_LIMIT_MAX_REQUESTS} requests per minute. Please try again later.`, - retryAfter: Math.ceil((recentRequests[0] + RATE_LIMIT_WINDOW_MS - now) / 1000), - }), { - status: 429, - headers: { - ...corsHeaders(origin), - 'Content-Type': 'application/json', - 'Retry-After': Math.ceil((recentRequests[0] + RATE_LIMIT_WINDOW_MS - now) / 1000).toString(), - }, - }); - } - - recentRequests.push(now); - await env.RATE_LIMIT_KV.put(rateLimitKey, JSON.stringify({ requests: recentRequests }), { - expirationTtl: 120, - }); - } - - try { - const response = await fetch(targetUrl, { - headers: { - 'User-Agent': 'BentoPDF-CertProxy/1.0', - }, - }); - - if (!response.ok) { - return new Response(JSON.stringify({ - error: 'Failed to fetch certificate', - status: response.status, - statusText: response.statusText, - }), { - status: response.status, - headers: { - ...corsHeaders(origin), - 'Content-Type': 'application/json', - }, - }); - } - - const contentLength = parseInt(response.headers.get('Content-Length') || '0', 10); - if (contentLength > MAX_FILE_SIZE_BYTES) { - return new Response(JSON.stringify({ - error: 'File too large', - message: `Certificate file exceeds maximum size of ${MAX_FILE_SIZE_BYTES / 1024}KB`, - size: contentLength, - maxSize: MAX_FILE_SIZE_BYTES, - }), { - status: 413, - headers: { - ...corsHeaders(origin), - 'Content-Type': 'application/json', - }, - }); - } - - const certData = await response.arrayBuffer(); - - if (certData.byteLength > MAX_FILE_SIZE_BYTES) { - return new Response(JSON.stringify({ - error: 'File too large', - message: `Certificate file exceeds maximum size of ${MAX_FILE_SIZE_BYTES / 1024}KB`, - size: certData.byteLength, - maxSize: MAX_FILE_SIZE_BYTES, - }), { - status: 413, - headers: { - ...corsHeaders(origin), - 'Content-Type': 'application/json', - }, - }); - } - - return new Response(certData, { - status: 200, - headers: { - ...corsHeaders(origin), - 'Content-Type': response.headers.get('Content-Type') || 'application/x-x509-ca-cert', - 'Content-Length': certData.byteLength.toString(), - 'Cache-Control': 'public, max-age=86400', - }, - }); - } catch (error) { - return new Response(JSON.stringify({ - error: 'Proxy error', - message: error.message, - }), { - status: 500, - headers: { - ...corsHeaders(origin), - 'Content-Type': 'application/json', - }, - }); - } - }, + ); + } + }, }; diff --git a/cloudflare/wasm-proxy-worker.js b/cloudflare/wasm-proxy-worker.js new file mode 100644 index 0000000..a61346e --- /dev/null +++ b/cloudflare/wasm-proxy-worker.js @@ -0,0 +1,356 @@ +/** + * BentoPDF WASM Proxy Worker + * + * This Cloudflare Worker proxies WASM module requests to bypass CORS restrictions. + * It fetches WASM libraries (PyMuPDF, Ghostscript, CoherentPDF) from configured sources + * and serves them with proper CORS headers. + * + * Endpoints: + * - /pymupdf/* - Proxies to PyMuPDF WASM source + * - /gs/* - Proxies to Ghostscript WASM source + * - /cpdf/* - Proxies to CoherentPDF WASM source + * + * Deploy: cd cloudflare && npx wrangler deploy -c wasm-wrangler.toml + * + * Required Environment Variables (set in Cloudflare dashboard): + * - PYMUPDF_SOURCE: Base URL for PyMuPDF WASM files (e.g., https://cdn.example.com/pymupdf) + * - GS_SOURCE: Base URL for Ghostscript WASM files (e.g., https://cdn.example.com/gs) + * - CPDF_SOURCE: Base URL for CoherentPDF files (e.g., https://cdn.example.com/cpdf) + */ + +const ALLOWED_ORIGINS = ['https://www.bentopdf.com', 'https://bentopdf.com']; + +const MAX_FILE_SIZE_BYTES = 100 * 1024 * 1024; + +const RATE_LIMIT_MAX_REQUESTS = 100; +const RATE_LIMIT_WINDOW_MS = 60 * 1000; + +const CACHE_TTL_SECONDS = 604800; + +const ALLOWED_EXTENSIONS = [ + '.js', + '.mjs', + '.wasm', + '.data', + '.py', + '.so', + '.zip', + '.json', + '.mem', + '.asm.js', + '.worker.js', + '.html', +]; + +function isAllowedOrigin(origin) { + if (!origin) return true; // Allow no-origin requests (e.g., direct browser navigation) + return ALLOWED_ORIGINS.some((allowed) => + origin.startsWith(allowed.replace(/\/$/, '')) + ); +} + +function isAllowedFile(pathname) { + const ext = pathname.substring(pathname.lastIndexOf('.')).toLowerCase(); + if (ALLOWED_EXTENSIONS.includes(ext)) return true; + + if (!pathname.includes('.') || pathname.endsWith('/')) return true; + + return false; +} + +function corsHeaders(origin) { + return { + 'Access-Control-Allow-Origin': origin || '*', + 'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Range, Cache-Control', + 'Access-Control-Expose-Headers': + 'Content-Length, Content-Range, Content-Type', + 'Access-Control-Max-Age': '86400', + }; +} + +function handleOptions(request) { + const origin = request.headers.get('Origin'); + return new Response(null, { + status: 204, + headers: corsHeaders(origin), + }); +} + +function getContentType(pathname) { + const ext = pathname.substring(pathname.lastIndexOf('.')).toLowerCase(); + const contentTypes = { + '.js': 'application/javascript', + '.mjs': 'application/javascript', + '.wasm': 'application/wasm', + '.json': 'application/json', + '.data': 'application/octet-stream', + '.py': 'text/x-python', + '.so': 'application/octet-stream', + '.zip': 'application/zip', + '.mem': 'application/octet-stream', + '.html': 'text/html', + }; + return contentTypes[ext] || 'application/octet-stream'; +} + +async function proxyRequest(request, env, sourceBaseUrl, subpath, origin) { + if (!sourceBaseUrl) { + return new Response( + JSON.stringify({ + error: 'Source not configured', + message: 'This WASM module source URL has not been configured.', + }), + { + status: 503, + headers: { + ...corsHeaders(origin), + 'Content-Type': 'application/json', + }, + } + ); + } + + const normalizedBase = sourceBaseUrl.endsWith('/') + ? sourceBaseUrl.slice(0, -1) + : sourceBaseUrl; + const normalizedPath = subpath.startsWith('/') ? subpath : `/${subpath}`; + const targetUrl = `${normalizedBase}${normalizedPath}`; + + if (!isAllowedFile(normalizedPath)) { + return new Response( + JSON.stringify({ + error: 'Forbidden file type', + message: 'Only WASM-related file types are allowed.', + }), + { + status: 403, + headers: { + ...corsHeaders(origin), + 'Content-Type': 'application/json', + }, + } + ); + } + + try { + const cacheKey = new Request(targetUrl, request); + const cache = caches.default; + let response = await cache.match(cacheKey); + + if (!response) { + response = await fetch(targetUrl, { + headers: { + 'User-Agent': 'BentoPDF-WASM-Proxy/1.0', + Accept: '*/*', + }, + }); + + if (!response.ok) { + return new Response( + JSON.stringify({ + error: 'Failed to fetch resource', + status: response.status, + statusText: response.statusText, + targetUrl: targetUrl, + }), + { + status: response.status, + headers: { + ...corsHeaders(origin), + 'Content-Type': 'application/json', + }, + } + ); + } + + const contentLength = parseInt( + response.headers.get('Content-Length') || '0', + 10 + ); + if (contentLength > MAX_FILE_SIZE_BYTES) { + return new Response( + JSON.stringify({ + error: 'File too large', + message: `File exceeds maximum size of ${MAX_FILE_SIZE_BYTES / 1024 / 1024}MB`, + }), + { + status: 413, + headers: { + ...corsHeaders(origin), + 'Content-Type': 'application/json', + }, + } + ); + } + + response = new Response(response.body, response); + response.headers.set( + 'Cache-Control', + `public, max-age=${CACHE_TTL_SECONDS}` + ); + + if (response.status === 200) { + await cache.put(cacheKey, response.clone()); + } + } + + const bodyData = await response.arrayBuffer(); + + return new Response(bodyData, { + status: 200, + headers: { + ...corsHeaders(origin), + 'Content-Type': getContentType(normalizedPath), + 'Content-Length': bodyData.byteLength.toString(), + 'Cache-Control': `public, max-age=${CACHE_TTL_SECONDS}`, + 'X-Proxied-From': new URL(targetUrl).hostname, + }, + }); + } catch (error) { + return new Response( + JSON.stringify({ + error: 'Proxy error', + message: error.message, + }), + { + status: 500, + headers: { + ...corsHeaders(origin), + 'Content-Type': 'application/json', + }, + } + ); + } +} + +export default { + async fetch(request, env, ctx) { + const url = new URL(request.url); + const pathname = url.pathname; + const origin = request.headers.get('Origin'); + + if (request.method === 'OPTIONS') { + return handleOptions(request); + } + + if (!isAllowedOrigin(origin)) { + return new Response( + JSON.stringify({ + error: 'Forbidden', + message: + 'Origin not allowed. Add your domain to ALLOWED_ORIGINS if self-hosting.', + }), + { + status: 403, + headers: { + 'Content-Type': 'application/json', + ...corsHeaders(origin), + }, + } + ); + } + + if (request.method !== 'GET' && request.method !== 'HEAD') { + return new Response('Method not allowed', { + status: 405, + headers: corsHeaders(origin), + }); + } + + if (env.RATE_LIMIT_KV) { + const clientIP = request.headers.get('CF-Connecting-IP') || 'unknown'; + const rateLimitKey = `wasm-ratelimit:${clientIP}`; + const now = Date.now(); + + const rateLimitData = await env.RATE_LIMIT_KV.get(rateLimitKey, { + type: 'json', + }); + const requests = rateLimitData?.requests || []; + const recentRequests = requests.filter( + (t) => now - t < RATE_LIMIT_WINDOW_MS + ); + + if (recentRequests.length >= RATE_LIMIT_MAX_REQUESTS) { + return new Response( + JSON.stringify({ + error: 'Rate limit exceeded', + message: `Maximum ${RATE_LIMIT_MAX_REQUESTS} requests per minute.`, + }), + { + status: 429, + headers: { + ...corsHeaders(origin), + 'Content-Type': 'application/json', + 'Retry-After': '60', + }, + } + ); + } + + recentRequests.push(now); + await env.RATE_LIMIT_KV.put( + rateLimitKey, + JSON.stringify({ requests: recentRequests }), + { + expirationTtl: 120, + } + ); + } + + if (pathname.startsWith('/pymupdf/')) { + const subpath = pathname.replace('/pymupdf', ''); + return proxyRequest(request, env, env.PYMUPDF_SOURCE, subpath, origin); + } + + if (pathname.startsWith('/gs/')) { + const subpath = pathname.replace('/gs', ''); + return proxyRequest(request, env, env.GS_SOURCE, subpath, origin); + } + + if (pathname.startsWith('/cpdf/')) { + const subpath = pathname.replace('/cpdf', ''); + return proxyRequest(request, env, env.CPDF_SOURCE, subpath, origin); + } + + if (pathname === '/' || pathname === '/health') { + return new Response( + JSON.stringify({ + service: 'BentoPDF WASM Proxy', + version: '1.0.0', + endpoints: { + pymupdf: '/pymupdf/*', + gs: '/gs/*', + cpdf: '/cpdf/*', + }, + configured: { + pymupdf: !!env.PYMUPDF_SOURCE, + gs: !!env.GS_SOURCE, + cpdf: !!env.CPDF_SOURCE, + }, + }), + { + status: 200, + headers: { + ...corsHeaders(origin), + 'Content-Type': 'application/json', + }, + } + ); + } + + return new Response( + JSON.stringify({ + error: 'Not Found', + message: 'Use /pymupdf/*, /gs/*, or /cpdf/* endpoints', + }), + { + status: 404, + headers: { + ...corsHeaders(origin), + 'Content-Type': 'application/json', + }, + } + ); + }, +}; diff --git a/cloudflare/wasm-wrangler.toml b/cloudflare/wasm-wrangler.toml new file mode 100644 index 0000000..c994f32 --- /dev/null +++ b/cloudflare/wasm-wrangler.toml @@ -0,0 +1,69 @@ +name = "bentopdf-wasm-proxy" +main = "wasm-proxy-worker.js" +compatibility_date = "2024-01-01" + +# ============================================================================= +# DEPLOYMENT +# ============================================================================= +# Deploy this worker: +# cd cloudflare +# npx wrangler deploy -c wasm-wrangler.toml +# +# Set environment secrets (one of the following methods): +# Option A: Cloudflare Dashboard +# Go to Workers & Pages > bentopdf-wasm-proxy > Settings > Variables +# Add: PYMUPDF_SOURCE, GS_SOURCE, CPDF_SOURCE +# +# Option B: Wrangler CLI +# npx wrangler secret put PYMUPDF_SOURCE -c wasm-wrangler.toml +# npx wrangler secret put GS_SOURCE -c wasm-wrangler.toml +# npx wrangler secret put CPDF_SOURCE -c wasm-wrangler.toml + +# ============================================================================= +# WASM SOURCE URLS +# ============================================================================= +# Set these as secrets in the Cloudflare dashboard or via wrangler: +# +# PYMUPDF_SOURCE: Base URL to PyMuPDF WASM files +# Example: https://cdn.jsdelivr.net/npm/@bentopdf/pymupdf-wasm/assets +# https://your-bucket.r2.cloudflarestorage.com/pymupdf +# +# GS_SOURCE: Base URL to Ghostscript WASM files +# Example: https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm/assets +# https://your-bucket.r2.cloudflarestorage.com/gs +# +# CPDF_SOURCE: Base URL to CoherentPDF files +# Example: https://cdn.jsdelivr.net/npm/coherentpdf/cpdf +# https://your-bucket.r2.cloudflarestorage.com/cpdf + +# ============================================================================= +# USAGE FROM BENTOPDF +# ============================================================================= +# In BentoPDF's WASM Settings page, configure URLs like: +# PyMuPDF: https://wasm.bentopdf.com/pymupdf/ +# Ghostscript: https://wasm.bentopdf.com/gs/ +# CoherentPDF: https://wasm.bentopdf.com/cpdf/ + +# ============================================================================= +# RATE LIMITING (Optional but recommended) +# ============================================================================= +# Create KV namespace: +# npx wrangler kv namespace create "RATE_LIMIT_KV" +# +# Then uncomment and update the ID below: +# [[kv_namespaces]] +# binding = "RATE_LIMIT_KV" +# id = "" + +# Use the same KV namespace as the CORS proxy if you want shared rate limiting +[[kv_namespaces]] +binding = "RATE_LIMIT_KV" +id = "b88e030b308941118cd484e3fcb3ae49" + +# ============================================================================= +# CUSTOM DOMAIN (Optional) +# ============================================================================= +# If you want a custom domain like wasm.bentopdf.com: +# routes = [ +# { pattern = "wasm.bentopdf.com/*", zone_name = "bentopdf.com" } +# ] diff --git a/docker-compose.yml b/docker-compose.yml index 4999418..95dbe02 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,8 +1,12 @@ services: bentopdf: - # simple mode - bentopdf/bentopdf-simple:latest - # default mode - bentopdf/bentopdf:latest - image: bentopdf/bentopdf-simple:latest + # GitHub Container Registry (Recommended) + # simple mode - ghcr.io/alam00000/bentopdf-simple:latest + # default mode - ghcr.io/alam00000/bentopdf:latest + # Docker Hub (Alternative) + # simple mode - bentopdfteam/bentopdf-simple:latest + # default mode - bentopdfteam/bentopdf:latest + image: ghcr.io/alam00000/bentopdf-simple:latest container_name: bentopdf restart: unless-stopped ports: diff --git a/docs/getting-started.md b/docs/getting-started.md index 3235ee9..7d3a257 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -14,6 +14,15 @@ Visit [bentopdf.com](https://bentopdf.com) to use BentoPDF instantly—no instal ### Option 2: Self-Host with Docker +> [!IMPORTANT] +> Office file conversion requires `SharedArrayBuffer`, which needs both: +> +> - `Cross-Origin-Opener-Policy: same-origin` +> - `Cross-Origin-Embedder-Policy: require-corp` +> - a secure context +> +> `http://localhost` works for local testing because browsers treat loopback as trustworthy. `http://192.168.x.x` or other LAN IPs usually do not, so Word/Excel/PowerPoint conversions will require HTTPS when accessed from other devices on your network. + ```bash # Pull and run the Docker image docker run -d -p 3000:8080 ghcr.io/alam00000/bentopdf:latest @@ -25,6 +34,9 @@ docker compose up -d Then open `http://localhost:3000` in your browser. +> [!NOTE] +> If you are preparing an air-gapped OCR deployment, you must host the OCR text-layer fonts internally in addition to the Tesseract worker, core runtime, and traineddata files. The full setup is documented in [Self-Hosting](/self-hosting/), including `VITE_OCR_FONT_BASE_URL` and the bundled `ocr-fonts/` directory. + ### Option 3: Build from Source ```bash @@ -41,14 +53,14 @@ npm run dev ## Features at a Glance -| Category | Tools | -|----------|-------| -| **Convert to PDF** | Word, Excel, PowerPoint, Images, Markdown, EPUB, MOBI, and more | -| **Convert from PDF** | JPG, PNG, Text, Excel, SVG, and more | -| **Edit & Annotate** | Sign, Highlight, Redact, Fill Forms, Add Stamps | -| **Organize** | Merge, Split, Rotate, Delete Pages, Reorder | -| **Optimize** | Compress, Repair, Flatten, OCR | -| **Security** | Encrypt, Decrypt, Remove Restrictions | +| Category | Tools | +| -------------------- | --------------------------------------------------------------- | +| **Convert to PDF** | Word, Excel, PowerPoint, Images, Markdown, EPUB, MOBI, and more | +| **Convert from PDF** | JPG, PNG, Text, Excel, SVG, and more | +| **Edit & Annotate** | Sign, Highlight, Redact, Fill Forms, Add Stamps | +| **Organize** | Merge, Split, Rotate, Delete Pages, Reorder | +| **Optimize** | Compress, Repair, Flatten, OCR | +| **Security** | Encrypt, Decrypt, Remove Restrictions | ## Browser Support diff --git a/docs/index.md b/docs/index.md index 5ba49b4..248d1eb 100644 --- a/docs/index.md +++ b/docs/index.md @@ -3,8 +3,8 @@ layout: home hero: - name: "BentoPDF" - text: "Free, Open-Source PDF Tools" + name: 'BentoPDF' + text: 'Free, Open-Source PDF Tools' tagline: Process PDFs entirely in your browser. No uploads. No servers. Complete privacy. actions: - theme: brand @@ -30,7 +30,13 @@ features: - icon: 🛠️ title: 50+ Tools details: Convert, edit, merge, split, compress, sign, OCR, and more. Everything you need in one place. + - icon: 🌐 title: Self-Hostable - details: Deploy on your own infrastructure. Docker, Vercel, Netlify, AWS, or any static hosting. + details: Deploy on your own infrastructure. Docker, Vercel, Netlify, AWS, or fully air-gapped environments with self-hosted OCR workers, language data, and text-layer fonts. --- + +## Offline OCR + +If you self-host BentoPDF in an air-gapped or offline environment, OCR needs more than the Tesseract worker and traineddata files. Searchable PDF output also needs the OCR text-layer fonts to be served internally. +See [Self-Hosting](/self-hosting/) for the full setup, including `VITE_OCR_FONT_BASE_URL`, the bundled `ocr-fonts/` directory, and the updated air-gap workflow. diff --git a/docs/licensing.md b/docs/licensing.md index 7bdde64..3f9bb1d 100644 --- a/docs/licensing.md +++ b/docs/licensing.md @@ -12,13 +12,13 @@ For complete licensing information, delivery details, AGPL component notices, an ## When Do You Need a Commercial License? -| Use Case | License Required | -|----------|------------------| -| Open-source project with public source code | AGPL-3.0 (Free) | -| Internal company tool (not distributed) | AGPL-3.0 (Free) | -| Proprietary/closed-source application | **Commercial License** | +| Use Case | License Required | +| ------------------------------------------- | ---------------------- | +| Open-source project with public source code | AGPL-3.0 (Free) | +| Internal company tool (not distributed) | AGPL-3.0 (Free) | +| Proprietary/closed-source application | **Commercial License** | | SaaS product without source code disclosure | **Commercial License** | -| Redistributing without AGPL compliance | **Commercial License** | +| Redistributing without AGPL compliance | **Commercial License** | ## Delivery & Licensing Model @@ -30,12 +30,28 @@ For complete licensing information, delivery details, AGPL component notices, an ## Important Notice on Third-Party Components -::: warning AGPL Components -This software includes components licensed under the **GNU AGPL v3**, such as CPDF. +::: info AGPL Components — Pre-configured via CDN +BentoPDF **does not bundle** AGPL-licensed processing libraries in its source code. These components are loaded at runtime from CDN URLs that are **pre-configured by default** — all features work out of the box with zero setup. -- This commercial license **does not** grant rights to use AGPL components in a closed-source manner. -- Users must comply with the AGPL v3 terms for these components. -- Source code for all AGPL components is included in the distribution. +| Component | License | Status | +| --------------- | -------- | ---------------------- | +| **PyMuPDF** | AGPL-3.0 | Pre-configured via CDN | +| **Ghostscript** | AGPL-3.0 | Pre-configured via CDN | +| **CoherentPDF** | AGPL-3.0 | Pre-configured via CDN | + +WASM module URLs are configured via environment variables at build time (`.env.production`). The defaults point to jsDelivr CDN. For custom deployments (air-gapped, self-hosted), you can override via environment variables, Docker build args, or per-user via **Advanced Settings** in the UI. + +See [Self-Hosting > WASM Configuration](/self-hosting/#wasm-configuration-agpl-components) for details. + +This approach ensures: + +- BentoPDF's core code remains under its dual-license (AGPL-3.0 / Commercial) +- WASM binaries are loaded at runtime, not included in the source +- Clear compliance boundaries for commercial users + ::: + +::: tip Commercial License & AGPL Features +The commercial license covers BentoPDF's own code. If you configure and use AGPL components (PyMuPDF, Ghostscript, CoherentPDF), you must still comply with their respective AGPL-3.0 license terms, which may require source code disclosure if you distribute modified versions. ::: ## Invoicing @@ -47,15 +63,15 @@ This software includes components licensed under the **GNU AGPL v3**, such as CP ## What's Included -| Feature | Included | -|---------|----------| -| Full source code | ✅ | -| All 50+ PDF tools | ✅ | -| Self-hosting rights | ✅ | -| Lifetime updates | ✅ | -| Remove branding (Simple Mode) | ✅ | -| Commercial support | ✅ (via email) | -| Priority feature requests | ✅ | +| Feature | Included | +| ----------------------------- | -------------- | +| Full source code | ✅ | +| All 50+ PDF tools | ✅ | +| Self-hosting rights | ✅ | +| Lifetime updates | ✅ | +| Remove branding (Simple Mode) | ✅ | +| Commercial support | ✅ (via email) | +| Priority feature requests | ✅ | ## FAQ @@ -69,7 +85,7 @@ Yes, with a commercial license. Without it, you must comply with AGPL-3.0, which ### What about the AGPL components? -Components like CPDF are licensed under AGPL v3 and remain under that license. The commercial license covers BentoPDF's own code but does not override third-party AGPL obligations. +Components like CoherentPDF are licensed under AGPL v3 and remain under that license. The commercial license covers BentoPDF's own code but does not override third-party AGPL obligations. ### How do I get an invoice? diff --git a/docs/self-hosting/apache.md b/docs/self-hosting/apache.md index 4b80551..129c0ac 100644 --- a/docs/self-hosting/apache.md +++ b/docs/self-hosting/apache.md @@ -17,6 +17,12 @@ npm install npm run build ``` +To customize branding, set environment variables before building: + +```bash +VITE_BRAND_NAME="AcmePDF" VITE_BRAND_LOGO="images/acme-logo.svg" npm run build +``` + ## Step 2: Copy Files ```bash @@ -52,6 +58,9 @@ Create `/etc/apache2/sites-available/bentopdf.conf`: # WASM MIME type AddType application/wasm .wasm + # Prevent double-compression of pre-compressed files + SetEnvIfNoCase Request_URI "\.gz$" no-gzip + # Compression AddOutputFilterByType DEFLATE text/html text/plain text/css application/javascript application/json application/wasm @@ -67,9 +76,24 @@ Create `/etc/apache2/sites-available/bentopdf.conf`: ExpiresByType image/svg+xml "access plus 1 year" + # Required headers for SharedArrayBuffer (LibreOffice WASM) + Header always set Cross-Origin-Embedder-Policy "require-corp" + Header always set Cross-Origin-Opener-Policy "same-origin" + Header always set Cross-Origin-Resource-Policy "cross-origin" + # Security headers Header always set X-Frame-Options "SAMEORIGIN" Header always set X-Content-Type-Options "nosniff" + + # Pre-compressed LibreOffice WASM files + + ForceType application/wasm + Header set Content-Encoding "gzip" + + + ForceType application/octet-stream + Header set Content-Encoding "gzip" + ``` @@ -191,6 +215,32 @@ Check that mod_rewrite is enabled: sudo a2enmod rewrite ``` +### Word/ODT/Excel to PDF Not Working + +LibreOffice WASM requires `SharedArrayBuffer`, which needs these headers: + +```apache +Header always set Cross-Origin-Embedder-Policy "require-corp" +Header always set Cross-Origin-Opener-Policy "same-origin" +``` + +It also needs a secure context. `http://localhost` works for local testing, but `http://192.168.x.x` or other LAN IPs usually require HTTPS. If the headers are present but `window.crossOriginIsolated` is still `false`, check whether the page is being opened over plain HTTP on a non-loopback origin. + +The pre-compressed `.wasm.gz` and `.data.gz` files also need correct `Content-Encoding`: + +```apache + + ForceType application/wasm + Header set Content-Encoding "gzip" + + + ForceType application/octet-stream + Header set Content-Encoding "gzip" + +``` + +Ensure `mod_headers` is enabled: `sudo a2enmod headers` + ### Permission Denied ```bash diff --git a/docs/self-hosting/aws.md b/docs/self-hosting/aws.md index 43e0e4d..7d18dee 100644 --- a/docs/self-hosting/aws.md +++ b/docs/self-hosting/aws.md @@ -23,7 +23,8 @@ aws s3 website s3://your-bentopdf-bucket \ ## Step 2: Build and Upload ```bash -# Build the project +# Build the project (optionally with custom branding) +# VITE_BRAND_NAME="AcmePDF" VITE_BRAND_LOGO="images/acme-logo.svg" npm run build npm run build # Sync to S3 @@ -56,6 +57,62 @@ Or use the AWS Console: 4. Default root object: `index.html` 5. Create distribution +## Step 3b: Response Headers Policy (Required for LibreOffice WASM) + +LibreOffice-based conversions (Word, Excel, PowerPoint to PDF) require `SharedArrayBuffer`, which needs specific response headers. Create a CloudFront Response Headers Policy: + +1. Go to CloudFront → Policies → Response headers +2. Create a custom policy with these headers: + +| Header | Value | +| ------------------------------ | -------------- | +| `Cross-Origin-Embedder-Policy` | `require-corp` | +| `Cross-Origin-Opener-Policy` | `same-origin` | +| `Cross-Origin-Resource-Policy` | `cross-origin` | + +3. Attach the policy to your distribution's default cache behavior + +Or via CLI: + +```bash +aws cloudfront create-response-headers-policy \ + --response-headers-policy-config '{ + "Name": "BentoPDF-COEP-COOP", + "CustomHeadersConfig": { + "Quantity": 3, + "Items": [ + {"Header": "Cross-Origin-Embedder-Policy", "Value": "require-corp", "Override": true}, + {"Header": "Cross-Origin-Opener-Policy", "Value": "same-origin", "Override": true}, + {"Header": "Cross-Origin-Resource-Policy", "Value": "cross-origin", "Override": true} + ] + } + }' +``` + +## Step 3c: S3 Metadata for Pre-Compressed WASM Files + +The LibreOffice WASM files are pre-compressed (`.wasm.gz`, `.data.gz`). Set the correct Content-Type and Content-Encoding so browsers decompress them: + +```bash +# Set correct headers for soffice.wasm.gz +aws s3 cp s3://your-bentopdf-bucket/libreoffice-wasm/soffice.wasm.gz \ + s3://your-bentopdf-bucket/libreoffice-wasm/soffice.wasm.gz \ + --content-type "application/wasm" \ + --content-encoding "gzip" \ + --metadata-directive REPLACE + +# Set correct headers for soffice.data.gz +aws s3 cp s3://your-bentopdf-bucket/libreoffice-wasm/soffice.data.gz \ + s3://your-bentopdf-bucket/libreoffice-wasm/soffice.data.gz \ + --content-type "application/octet-stream" \ + --content-encoding "gzip" \ + --metadata-directive REPLACE +``` + +::: warning Important +Without the response headers policy, `SharedArrayBuffer` is unavailable and LibreOffice WASM conversions will hang at ~55%. Without the correct Content-Encoding on the `.gz` files, the browser receives raw gzip bytes and WASM compilation fails. +::: + ## Step 4: S3 Bucket Policy Allow CloudFront to access the bucket: @@ -94,11 +151,11 @@ Configure 404 to return `index.html` for SPA routing: ## Cost Estimation -| Resource | Estimated Cost | -|----------|----------------| -| S3 Storage (~500MB) | ~$0.01/month | -| CloudFront (1TB transfer) | ~$85/month | -| CloudFront (10GB transfer) | ~$0.85/month | +| Resource | Estimated Cost | +| -------------------------- | -------------- | +| S3 Storage (~500MB) | ~$0.01/month | +| CloudFront (1TB transfer) | ~$85/month | +| CloudFront (10GB transfer) | ~$0.85/month | ::: tip Use S3 Intelligent Tiering for cost optimization on infrequently accessed files. @@ -117,15 +174,15 @@ resource "aws_cloudfront_distribution" "bentopdf" { domain_name = aws_s3_bucket.bentopdf.bucket_regional_domain_name origin_id = "S3Origin" } - + enabled = true default_root_object = "index.html" - + default_cache_behavior { allowed_methods = ["GET", "HEAD"] cached_methods = ["GET", "HEAD"] target_origin_id = "S3Origin" - + viewer_protocol_policy = "redirect-to-https" } } diff --git a/docs/self-hosting/cloudflare.md b/docs/self-hosting/cloudflare.md index 0d8850f..c2258c7 100644 --- a/docs/self-hosting/cloudflare.md +++ b/docs/self-hosting/cloudflare.md @@ -10,27 +10,49 @@ ## Build Configuration -| Setting | Value | -|---------|-------| -| Framework preset | None | -| Build command | `npm run build` | -| Build output directory | `dist` | -| Root directory | `/` | +| Setting | Value | +| ---------------------- | --------------- | +| Framework preset | None | +| Build command | `npm run build` | +| Build output directory | `dist` | +| Root directory | `/` | ## Environment Variables Add these in Settings → Environment variables: -| Variable | Value | -|----------|-------| -| `NODE_VERSION` | `18` | -| `SIMPLE_MODE` | `false` (optional) | +| Variable | Value | +| ----------------------- | ------------------------------------------ | +| `NODE_VERSION` | `18` | +| `SIMPLE_MODE` | `false` (optional) | +| `VITE_BRAND_NAME` | Custom brand name (optional) | +| `VITE_BRAND_LOGO` | Logo path relative to `public/` (optional) | +| `VITE_FOOTER_TEXT` | Custom footer/copyright text (optional) | +| `VITE_DEFAULT_LANGUAGE` | Default UI language, e.g. `fr` (optional) | ## Configuration File Create `_headers` in your `public` folder: ``` +# Required security headers for SharedArrayBuffer (used by LibreOffice WASM) +/* + Cross-Origin-Embedder-Policy: require-corp + Cross-Origin-Opener-Policy: same-origin + Cross-Origin-Resource-Policy: cross-origin + +# Pre-compressed LibreOffice WASM binary +/libreoffice-wasm/soffice.wasm.gz + Content-Type: application/wasm + Content-Encoding: gzip + Cache-Control: public, max-age=31536000, immutable + +# Pre-compressed LibreOffice WASM data +/libreoffice-wasm/soffice.data.gz + Content-Type: application/octet-stream + Content-Encoding: gzip + Cache-Control: public, max-age=31536000, immutable + # Cache WASM files aggressively /*.wasm Cache-Control: public, max-age=31536000, immutable @@ -41,6 +63,10 @@ Create `_headers` in your `public` folder: Cache-Control: no-cache ``` +::: warning Important +The `Cross-Origin-Embedder-Policy` and `Cross-Origin-Opener-Policy` headers are required for Word/ODT/Excel/PowerPoint to PDF conversions. Without them, `SharedArrayBuffer` is unavailable and the LibreOffice WASM engine will fail to initialize. +::: + Create `_redirects` for SPA routing: ``` @@ -89,11 +115,11 @@ npx wrangler deploy ### Security Features -| Feature | Description | -|---------|-------------| -| **URL Restrictions** | Only certificate URLs allowed | -| **File Size Limit** | Max 10MB per request | -| **Rate Limiting** | 60 req/IP/min (requires KV) | +| Feature | Description | +| ----------------------- | ------------------------------ | +| **URL Restrictions** | Only certificate URLs allowed | +| **File Size Limit** | Max 10MB per request | +| **Rate Limiting** | 60 req/IP/min (requires KV) | | **Private IP Blocking** | Blocks localhost, internal IPs | ### Enable Rate Limiting @@ -116,4 +142,13 @@ npx wrangler deploy VITE_CORS_PROXY_URL=https://your-worker.workers.dev npm run build ``` +Or with Docker: + +```bash +export VITE_CORS_PROXY_URL="https://your-worker.workers.dev" +DOCKER_BUILDKIT=1 docker build \ + --secret id=VITE_CORS_PROXY_URL,env=VITE_CORS_PROXY_URL \ + -t your-bentopdf . +``` + > **Note:** See [README](https://github.com/alam00000/bentopdf#digital-signature-cors-proxy-required) for HMAC signature setup. diff --git a/docs/self-hosting/cors-proxy.md b/docs/self-hosting/cors-proxy.md index 2797a19..210f3fd 100644 --- a/docs/self-hosting/cors-proxy.md +++ b/docs/self-hosting/cors-proxy.md @@ -2,6 +2,8 @@ The digital signature tool uses a CORS proxy to fetch issuer certificates from external Certificate Authorities (CAs). This is necessary because many CA servers don't include CORS headers in their responses, which prevents direct browser-based fetching. +Additionally, many CA servers serve certificates over plain HTTP. When your BentoPDF instance is hosted over HTTPS, browsers block these HTTP requests (mixed content policy). The CORS proxy resolves both issues by routing requests through an HTTPS endpoint with proper headers. + ## How It Works When signing a PDF with a certificate: @@ -13,35 +15,65 @@ When signing a PDF with a certificate: ## Self-Hosting the CORS Proxy -If you're self-hosting BentoPDF, you'll need to deploy your own CORS proxy. +If you're self-hosting BentoPDF, you'll need to deploy your own CORS proxy for digital signatures to work with certificates that require chain fetching. ### Option 1: Cloudflare Workers (Recommended) 1. **Install Wrangler CLI**: + ```bash npm install -g wrangler ``` 2. **Login to Cloudflare**: + ```bash wrangler login ``` -3. **Deploy the proxy**: +3. **Clone BentoPDF and update allowed origins**: + + ```bash + git clone https://github.com/alam00000/bentopdf.git + cd bentopdf/cloudflare + ``` + + Open `cors-proxy-worker.js` and change the `ALLOWED_ORIGINS` array to your domain: + + ```js + const ALLOWED_ORIGINS = [ + 'https://your-domain.com', + 'https://www.your-domain.com', + ]; + ``` + + ::: warning Important + Without this change, the proxy will reject all requests from your site with a **403 Forbidden** error. The default only allows requests from `bentopdf.com`. + ::: + +4. **Deploy the proxy**: + ```bash - cd cloudflare wrangler deploy ``` -4. **Update your environment**: - Create a `.env` or set in your hosting platform: - ``` - VITE_CORS_PROXY_URL=https://your-worker-name.your-subdomain.workers.dev + Note your worker URL (e.g., `https://bentopdf-cors-proxy.your-subdomain.workers.dev`). + +5. **Rebuild BentoPDF with the proxy URL**: + + If using Docker: + + ```bash + export VITE_CORS_PROXY_URL="https://your-worker.workers.dev" + DOCKER_BUILDKIT=1 docker build \ + --secret id=VITE_CORS_PROXY_URL,env=VITE_CORS_PROXY_URL \ + -t your-bentopdf . ``` -5. **Rebuild BentoPDF**: + If building from source: + ```bash - npm run build + VITE_CORS_PROXY_URL=https://your-worker.workers.dev npm run build ``` ### Option 2: Custom Backend Proxy @@ -51,27 +83,28 @@ You can also create your own proxy endpoint. The requirements are: 1. Accept GET requests with a `url` query parameter 2. Fetch the URL from your server (no CORS restrictions server-side) 3. Return the response with these headers: - - `Access-Control-Allow-Origin: *` (or your specific origin) + - `Access-Control-Allow-Origin: https://your-domain.com` - `Access-Control-Allow-Methods: GET, OPTIONS` - - `Content-Type: application/x-x509-ca-cert` + - `X-Content-Type-Options: nosniff` Example Express.js implementation: ```javascript app.get('/api/cert-proxy', async (req, res) => { const targetUrl = req.query.url; - + // Validate it's a certificate URL if (!isValidCertUrl(targetUrl)) { return res.status(400).json({ error: 'Invalid URL' }); } - + try { const response = await fetch(targetUrl); const data = await response.arrayBuffer(); - - res.set('Access-Control-Allow-Origin', '*'); - res.set('Content-Type', 'application/x-x509-ca-cert'); + + res.set('Access-Control-Allow-Origin', 'https://your-domain.com'); + res.set('Content-Type', 'application/octet-stream'); + res.set('X-Content-Type-Options', 'nosniff'); res.send(Buffer.from(data)); } catch (error) { res.status(500).json({ error: 'Proxy error' }); @@ -83,9 +116,15 @@ app.get('/api/cert-proxy', async (req, res) => { The included Cloudflare Worker has several security measures: -- **URL Validation**: Only allows certificate-related URLs (`.crt`, `.cer`, `.pem`, `/certs/`, `/ocsp`, `/crl`) -- **Blocked Domains**: Prevents access to localhost and private IP ranges -- **HTTP Methods**: Only allows GET requests +| Feature | Description | +| ----------------------- | -------------------------------------------------------------------------------------- | +| **Origin Validation** | Only allows requests from domains listed in `ALLOWED_ORIGINS` | +| **URL Restrictions** | Only allows certificate URLs (`.crt`, `.cer`, `.pem`, `/certs/`, `/ocsp`, `/crl`) | +| **Private IP Blocking** | Blocks IPv4/IPv6 private ranges, link-local, loopback, decimal IPs, and cloud metadata | +| **Content-Type Safety** | Only returns safe certificate MIME types, blocks upstream content-type injection | +| **File Size Limit** | Streams response with 10MB limit, aborts mid-download if exceeded | +| **Rate Limiting** | 60 requests per IP per minute (requires KV) | +| **HMAC Signatures** | Optional client-side signing (deters casual abuse) | ## Disabling the Proxy @@ -95,22 +134,39 @@ If you don't want to use a CORS proxy, set the environment variable to an empty VITE_CORS_PROXY_URL= ``` -**Note**: Without the proxy, signing with certificates that require external chain fetching (like FNMT or some corporate CAs) will fail. +**Note**: Without the proxy, signing with certificates that require external chain fetching (like FNMT or some corporate CAs) will fail with a "Failed to fetch" error. ## Troubleshooting -### "Failed to fetch certificate chain" Error +### "Signing error: TypeError: Failed to fetch" -1. Check that your CORS proxy is deployed and accessible -2. Verify the `VITE_CORS_PROXY_URL` is correctly set -3. Test the proxy directly: - ```bash - curl "https://your-proxy.workers.dev?url=https://www.cert.fnmt.es/certs/ACUSU.crt" - ``` +This usually means either: + +1. **No CORS proxy configured** — Set `VITE_CORS_PROXY_URL` and rebuild +2. **Mixed content blocked** — Your site is HTTPS but the certificate's issuer URL is HTTP. The CORS proxy resolves this. +3. **CORS proxy rejecting your origin** — Check that your domain is in the `ALLOWED_ORIGINS` array in `cors-proxy-worker.js` + +### "403 Forbidden" from the proxy + +Your domain is not in the `ALLOWED_ORIGINS` list. Edit `cors-proxy-worker.js`: + +```js +const ALLOWED_ORIGINS = ['https://your-domain.com']; +``` + +Then redeploy: `npx wrangler deploy` + +### Testing the proxy + +```bash +curl -H "Origin: https://your-domain.com" \ + "https://your-proxy.workers.dev?url=http://www.cert.fnmt.es/certs/ACUSU.crt" +``` ### Certificates That Work Without Proxy Some certificates include the full chain in the P12/PFX file and don't require external fetching: + - Self-signed certificates - Some commercial CAs that bundle intermediate certificates - Certificates you've manually assembled with the full chain diff --git a/docs/self-hosting/docker.md b/docs/self-hosting/docker.md index 0130afd..e1c58d4 100644 --- a/docs/self-hosting/docker.md +++ b/docs/self-hosting/docker.md @@ -1,27 +1,40 @@ -# Deploy with Docker +# Deploy with Docker / Podman The easiest way to self-host BentoPDF in a production environment. > [!IMPORTANT] > **Required Headers for Office File Conversion** -> +> > LibreOffice-based tools (Word, Excel, PowerPoint conversion) require these HTTP headers for `SharedArrayBuffer` support: +> > - `Cross-Origin-Opener-Policy: same-origin` > - `Cross-Origin-Embedder-Policy: require-corp` -> -> The official Docker images include these headers. If using a reverse proxy (Traefik, Caddy, etc.), ensure these headers are preserved or added. +> +> The page must also be served from a secure context. `http://localhost` works for local testing, but `http://192.168.x.x` or other LAN IPs usually do not qualify, so Office conversion over plain HTTP will fail even if the headers are present. +> +> The official container images include these headers. If using a reverse proxy (Traefik, Caddy, etc.), ensure these headers are preserved or added, and use HTTPS for non-loopback access. + +> [!TIP] +> **Podman Users:** All `docker` commands work with Podman by replacing `docker` with `podman` and `docker-compose` with `podman-compose`. ## Quick Start ```bash +# Docker docker run -d \ --name bentopdf \ -p 3000:8080 \ --restart unless-stopped \ ghcr.io/alam00000/bentopdf:latest + +# Podman +podman run -d \ + --name bentopdf \ + -p 3000:8080 \ + ghcr.io/alam00000/bentopdf:latest ``` -## Docker Compose +## Docker Compose / Podman Compose Create `docker-compose.yml`: @@ -31,10 +44,10 @@ services: image: ghcr.io/alam00000/bentopdf:latest container_name: bentopdf ports: - - "3000:8080" + - '3000:8080' restart: unless-stopped healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:8080"] + test: ['CMD', 'curl', '-f', 'http://localhost:8080'] interval: 30s timeout: 10s retries: 3 @@ -43,7 +56,11 @@ services: Run: ```bash +# Docker Compose docker compose up -d + +# Podman Compose +podman-compose up -d ``` ## Build Your Own Image @@ -73,20 +90,170 @@ docker run -d -p 3000:8080 bentopdf:custom ## Environment Variables -| Variable | Description | Default | -|----------|-------------|---------| -| `SIMPLE_MODE` | Build without LibreOffice tools | `false` | -| `BASE_URL` | Deploy to subdirectory | `/` | +| Variable | Description | Default | +| ------------------------------------ | ------------------------------------------- | -------------------------------------------------------------- | +| `SIMPLE_MODE` | Build without LibreOffice tools | `false` | +| `BASE_URL` | Deploy to subdirectory | `/` | +| `VITE_WASM_PYMUPDF_URL` | PyMuPDF WASM module URL | `https://cdn.jsdelivr.net/npm/@bentopdf/pymupdf-wasm@0.11.16/` | +| `VITE_WASM_GS_URL` | Ghostscript WASM module URL | `https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm/assets/` | +| `VITE_WASM_CPDF_URL` | CoherentPDF WASM module URL | `https://cdn.jsdelivr.net/npm/coherentpdf/dist/` | +| `VITE_TESSERACT_WORKER_URL` | OCR worker script URL | _(empty; use Tesseract.js default CDN)_ | +| `VITE_TESSERACT_CORE_URL` | OCR core runtime directory | _(empty; use Tesseract.js default CDN)_ | +| `VITE_TESSERACT_LANG_URL` | OCR traineddata directory | _(empty; use Tesseract.js default CDN)_ | +| `VITE_TESSERACT_AVAILABLE_LANGUAGES` | Comma-separated OCR languages exposed in UI | _(empty; show full catalog)_ | +| `VITE_OCR_FONT_BASE_URL` | OCR text-layer font directory | _(empty; use remote Noto font URLs)_ | +| `VITE_DEFAULT_LANGUAGE` | Default UI language | `en` | +| `VITE_BRAND_NAME` | Custom brand name | `BentoPDF` | +| `VITE_BRAND_LOGO` | Logo path relative to `public/` | `images/favicon-no-bg.svg` | +| `VITE_FOOTER_TEXT` | Custom footer/copyright text | `© 2026 BentoPDF. All rights reserved.` | + +WASM module URLs are pre-configured with CDN defaults — all advanced features work out of the box. Override these for air-gapped or self-hosted deployments. + +For OCR, leave the `VITE_TESSERACT_*` variables empty to use the default online assets, or set all three together for self-hosted/offline OCR. Partial OCR overrides are rejected because the worker, core runtime, and traineddata directory must match. For fully offline searchable PDF output, also set `VITE_OCR_FONT_BASE_URL` so the OCR text-layer fonts are loaded from your internal server instead of the public Noto font URLs. + +`VITE_DEFAULT_LANGUAGE` sets the UI language for first-time visitors. Supported values: `en`, `ar`, `be`, `fr`, `de`, `es`, `zh`, `zh-TW`, `vi`, `tr`, `id`, `it`, `pt`, `nl`, `da`. Users can still switch languages — this only changes the default. Example: ```bash -docker run -d \ - -e SIMPLE_MODE=true \ - -p 3000:8080 \ - ghcr.io/alam00000/bentopdf:latest +# Build with French as the default language +docker build --build-arg VITE_DEFAULT_LANGUAGE=fr -t bentopdf . +docker run -d -p 3000:8080 bentopdf ``` +### Custom Branding + +Replace the default BentoPDF logo, name, and footer text with your own. Place your logo file in the `public/` folder (or use an existing image), then pass the branding variables at build time: + +```bash +docker build \ + --build-arg VITE_BRAND_NAME="AcmePDF" \ + --build-arg VITE_BRAND_LOGO="images/acme-logo.svg" \ + --build-arg VITE_FOOTER_TEXT="© 2026 Acme Corp. Internal use only." \ + -t acmepdf . +``` + +Branding works in both full mode and Simple Mode, and can be combined with all other build-time options. + +### Custom WASM URLs (Air-Gapped / Self-Hosted) + +> [!IMPORTANT] +> WASM URLs are baked into the JavaScript at **build time**. The WASM files are downloaded by the **user's browser** at runtime — Docker does not download them during the build. For air-gapped networks, you must host the WASM files on an internal server that browsers can reach. + +**Full air-gapped workflow:** + +```bash +# 1. On a machine WITH internet — download WASM packages +bash scripts/prepare-airgap.sh --list-ocr-languages +bash scripts/prepare-airgap.sh --search-ocr-language german + +# 2. Download WASM/OCR packages +npm pack @bentopdf/pymupdf-wasm@0.11.14 +npm pack @bentopdf/gs-wasm +npm pack coherentpdf +npm pack tesseract.js@7.0.0 +npm pack tesseract.js-core@7.0.0 +mkdir -p tesseract-langdata +curl -fsSL https://cdn.jsdelivr.net/npm/@tesseract.js-data/eng/4.0.0_best_int/eng.traineddata.gz -o tesseract-langdata/eng.traineddata.gz +mkdir -p ocr-fonts +curl -fsSL https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSans/NotoSans-Regular.ttf -o ocr-fonts/NotoSans-Regular.ttf + +# 3. Build the image with your internal server URLs +docker build \ + --build-arg VITE_WASM_PYMUPDF_URL=https://internal-server.example.com/wasm/pymupdf/ \ + --build-arg VITE_WASM_GS_URL=https://internal-server.example.com/wasm/gs/ \ + --build-arg VITE_WASM_CPDF_URL=https://internal-server.example.com/wasm/cpdf/ \ + --build-arg VITE_TESSERACT_WORKER_URL=https://internal-server.example.com/wasm/ocr/worker.min.js \ + --build-arg VITE_TESSERACT_CORE_URL=https://internal-server.example.com/wasm/ocr/core \ + --build-arg VITE_TESSERACT_LANG_URL=https://internal-server.example.com/wasm/ocr/lang-data \ + --build-arg VITE_TESSERACT_AVAILABLE_LANGUAGES=eng,deu \ + --build-arg VITE_OCR_FONT_BASE_URL=https://internal-server.example.com/wasm/ocr/fonts \ + -t bentopdf . + +# 4. Export the image +docker save bentopdf -o bentopdf.tar + +# 5. Transfer bentopdf.tar + the .tgz packages + tesseract-langdata/ + ocr-fonts/ into the air-gapped network + +# 6. Inside the air-gapped network — load and run +docker load -i bentopdf.tar + +# Extract WASM packages to your internal web server +mkdir -p /var/www/wasm/pymupdf /var/www/wasm/gs /var/www/wasm/cpdf /var/www/wasm/ocr/core /var/www/wasm/ocr/lang-data /var/www/wasm/ocr/fonts +tar xzf bentopdf-pymupdf-wasm-0.11.14.tgz -C /var/www/wasm/pymupdf --strip-components=1 +tar xzf bentopdf-gs-wasm-*.tgz -C /var/www/wasm/gs --strip-components=1 +tar xzf coherentpdf-*.tgz -C /var/www/wasm/cpdf --strip-components=1 +TEMP_TESS=$(mktemp -d) +tar xzf tesseract.js-7.0.0.tgz -C "$TEMP_TESS" +cp "$TEMP_TESS/package/dist/worker.min.js" /var/www/wasm/ocr/worker.min.js +rm -rf "$TEMP_TESS" +tar xzf tesseract.js-core-7.0.0.tgz -C /var/www/wasm/ocr/core --strip-components=1 +cp ./tesseract-langdata/*.traineddata.gz /var/www/wasm/ocr/lang-data/ +cp ./ocr-fonts/* /var/www/wasm/ocr/fonts/ + +# Run BentoPDF +docker run -d -p 3000:8080 --restart unless-stopped bentopdf +``` + +Use the codes printed by `bash scripts/prepare-airgap.sh --list-ocr-languages`, or search by name with `bash scripts/prepare-airgap.sh --search-ocr-language `, for `--ocr-languages`. When you build with a restricted OCR subset, pass the same codes to `VITE_TESSERACT_AVAILABLE_LANGUAGES` so the app only shows bundled languages. For full offline OCR output, also host the bundled `ocr-fonts/` directory and point `VITE_OCR_FONT_BASE_URL` at it. + +Set a variable to empty string to disable that module (users must configure manually via Advanced Settings). + +## Custom User ID (PUID/PGID) + +For environments that require running as a specific non-root user (NAS devices, Kubernetes with security contexts, organizational policies), BentoPDF provides a separate Dockerfile with LSIO-style PUID/PGID support. + +### Build and Run + +```bash +# Build the non-root image +docker build -f Dockerfile.nonroot -t bentopdf-nonroot . + +# Run with custom UID/GID +docker run -d \ + --name bentopdf \ + -p 3000:8080 \ + -e PUID=1000 \ + -e PGID=1000 \ + --restart unless-stopped \ + bentopdf-nonroot +``` + +### Environment Variables + +| Variable | Description | Default | +| -------------- | --------------------- | ------- | +| `PUID` | User ID to run as | `1000` | +| `PGID` | Group ID to run as | `1000` | +| `DISABLE_IPV6` | Disable IPv6 listener | `false` | + +### Docker Compose + +```yaml +services: + bentopdf: + build: + context: . + dockerfile: Dockerfile.nonroot + container_name: bentopdf + ports: + - '3000:8080' + environment: + - PUID=1000 + - PGID=1000 + restart: unless-stopped +``` + +### How It Works + +The container starts as root, creates a user with the specified PUID/PGID, adjusts ownership on all writable directories, then drops privileges using `su-exec`. The nginx process runs entirely as your specified user. + +> [!NOTE] +> The standard `Dockerfile` uses `nginx-unprivileged` (UID 101) and is recommended for most deployments. Use `Dockerfile.nonroot` only when you need a specific UID/GID. + +> [!WARNING] +> PUID/PGID cannot be `0` (root). The entrypoint validates inputs and will exit with an error for invalid values. + ## With Traefik (Reverse Proxy) ```yaml @@ -94,15 +261,15 @@ services: traefik: image: traefik:v2.10 command: - - "--providers.docker=true" - - "--entrypoints.web.address=:80" - - "--entrypoints.websecure.address=:443" - - "--certificatesresolvers.letsencrypt.acme.email=you@example.com" - - "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json" - - "--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web" + - '--providers.docker=true' + - '--entrypoints.web.address=:80' + - '--entrypoints.websecure.address=:443' + - '--certificatesresolvers.letsencrypt.acme.email=you@example.com' + - '--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json' + - '--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web' ports: - - "80:80" - - "443:443" + - '80:80' + - '443:443' volumes: - /var/run/docker.sock:/var/run/docker.sock:ro - ./letsencrypt:/letsencrypt @@ -110,15 +277,15 @@ services: bentopdf: image: ghcr.io/alam00000/bentopdf:latest labels: - - "traefik.enable=true" - - "traefik.http.routers.bentopdf.rule=Host(`pdf.example.com`)" - - "traefik.http.routers.bentopdf.entrypoints=websecure" - - "traefik.http.routers.bentopdf.tls.certresolver=letsencrypt" - - "traefik.http.services.bentopdf.loadbalancer.server.port=8080" + - 'traefik.enable=true' + - 'traefik.http.routers.bentopdf.rule=Host(`pdf.example.com`)' + - 'traefik.http.routers.bentopdf.entrypoints=websecure' + - 'traefik.http.routers.bentopdf.tls.certresolver=letsencrypt' + - 'traefik.http.services.bentopdf.loadbalancer.server.port=8080' # Required headers for SharedArrayBuffer (LibreOffice WASM) - - "traefik.http.routers.bentopdf.middlewares=bentopdf-headers" - - "traefik.http.middlewares.bentopdf-headers.headers.customresponseheaders.Cross-Origin-Opener-Policy=same-origin" - - "traefik.http.middlewares.bentopdf-headers.headers.customresponseheaders.Cross-Origin-Embedder-Policy=require-corp" + - 'traefik.http.routers.bentopdf.middlewares=bentopdf-headers' + - 'traefik.http.middlewares.bentopdf-headers.headers.customresponseheaders.Cross-Origin-Opener-Policy=same-origin' + - 'traefik.http.middlewares.bentopdf-headers.headers.customresponseheaders.Cross-Origin-Embedder-Policy=require-corp' restart: unless-stopped ``` @@ -129,12 +296,12 @@ services: caddy: image: caddy:2 ports: - - "80:80" - - "443:443" + - '80:80' + - '443:443' volumes: - ./Caddyfile:/etc/caddy/Caddyfile - caddy_data:/data - + bentopdf: image: ghcr.io/alam00000/bentopdf:latest restart: unless-stopped @@ -169,6 +336,141 @@ services: memory: 128M ``` +## Podman Quadlet (Systemd Integration) + +[Quadlet](https://docs.podman.io/en/latest/markdown/podman-systemd.unit.5.html) allows you to run Podman containers as systemd services. This is ideal for production deployments on Linux systems. + +### Basic Quadlet Setup + +Create a container unit file at `~/.config/containers/systemd/bentopdf.container` (user) or `/etc/containers/systemd/bentopdf.container` (system): + +```ini +[Unit] +Description=BentoPDF - Privacy-first PDF toolkit +After=network-online.target +Wants=network-online.target + +[Container] +Image=ghcr.io/alam00000/bentopdf:latest +ContainerName=bentopdf +PublishPort=3000:8080 +AutoUpdate=registry + +[Service] +Restart=always +TimeoutStartSec=300 + +[Install] +WantedBy=default.target +``` + +### Enable and Start + +```bash +# Reload systemd to detect new unit +systemctl --user daemon-reload + +# Start the service +systemctl --user start bentopdf + +# Enable on boot +systemctl --user enable bentopdf + +# Check status +systemctl --user status bentopdf + +# View logs +journalctl --user -u bentopdf -f +``` + +> [!TIP] +> For system-wide deployment, use `systemctl` without `--user` flag and place the file in `/etc/containers/systemd/`. + +### Simple Mode Quadlet + +For Simple Mode deployment, create `bentopdf-simple.container`: + +```ini +[Unit] +Description=BentoPDF Simple Mode - Clean PDF toolkit +After=network-online.target +Wants=network-online.target + +[Container] +Image=ghcr.io/alam00000/bentopdf-simple:latest +ContainerName=bentopdf-simple +PublishPort=3000:8080 +AutoUpdate=registry + +[Service] +Restart=always +TimeoutStartSec=300 + +[Install] +WantedBy=default.target +``` + +### Quadlet with Health Check + +```ini +[Unit] +Description=BentoPDF with health monitoring +After=network-online.target +Wants=network-online.target + +[Container] +Image=ghcr.io/alam00000/bentopdf:latest +ContainerName=bentopdf +PublishPort=3000:8080 +AutoUpdate=registry +HealthCmd=curl -f http://localhost:8080 || exit 1 +HealthInterval=30s +HealthTimeout=10s +HealthRetries=3 + +[Service] +Restart=always +TimeoutStartSec=300 + +[Install] +WantedBy=default.target +``` + +### Auto-Update with Quadlet + +Podman can automatically update containers when new images are available: + +```bash +# Enable auto-update timer +systemctl --user enable --now podman-auto-update.timer + +# Check for updates manually +podman auto-update + +# Dry run (check without updating) +podman auto-update --dry-run +``` + +### Quadlet Network Configuration + +For custom network configuration, create a network file `bentopdf.network`: + +```ini +[Network] +Subnet=10.89.0.0/24 +Gateway=10.89.0.1 +``` + +Then reference it in your container file: + +```ini +[Container] +Image=ghcr.io/alam00000/bentopdf:latest +ContainerName=bentopdf +PublishPort=3000:8080 +Network=bentopdf.network +``` + ## Updating ```bash diff --git a/docs/self-hosting/index.md b/docs/self-hosting/index.md index a492ec2..3a79e1f 100644 --- a/docs/self-hosting/index.md +++ b/docs/self-hosting/index.md @@ -2,15 +2,24 @@ BentoPDF can be self-hosted on your own infrastructure. This guide covers various deployment options. -## Quick Start with Docker +## Quick Start with Docker / Podman The fastest way to self-host BentoPDF: +> [!IMPORTANT] +> Office file conversion requires `SharedArrayBuffer`, which means the app must be both cross-origin isolated and served from a secure context. The official image already sends the required COOP/COEP headers, but browsers still disable `SharedArrayBuffer` on plain HTTP local-network origins such as `http://192.168.x.x`. +> +> Use `http://localhost` only for same-device testing. If users access BentoPDF through a LAN IP or hostname, terminate it with HTTPS. + ```bash +# Docker docker run -d -p 3000:8080 ghcr.io/alam00000/bentopdf:latest + +# Podman +podman run -d -p 3000:8080 ghcr.io/alam00000/bentopdf:latest ``` -Or with Docker Compose: +Or with Docker Compose / Podman Compose: ```yaml # docker-compose.yml @@ -18,14 +27,43 @@ services: bentopdf: image: ghcr.io/alam00000/bentopdf:latest ports: - - "3000:8080" + - '3000:8080' restart: unless-stopped ``` ```bash +# Docker Compose docker compose up -d + +# Podman Compose +podman-compose up -d ``` +## Podman Quadlet (Linux Systemd) + +Run BentoPDF as a systemd service. Create `~/.config/containers/systemd/bentopdf.container`: + +```ini +[Container] +Image=ghcr.io/alam00000/bentopdf:latest +ContainerName=bentopdf +PublishPort=3000:8080 +AutoUpdate=registry + +[Service] +Restart=always + +[Install] +WantedBy=default.target +``` + +```bash +systemctl --user daemon-reload +systemctl --user enable --now bentopdf +``` + +See [Docker deployment guide](/self-hosting/docker) for full Quadlet documentation. + ## Building from Source ```bash @@ -45,6 +83,7 @@ npm run build Simple Mode is designed for internal organizational use where you want to hide all branding and marketing content, showing only the essential PDF tools. **What Simple Mode hides:** + - Navigation bar - Hero section with marketing content - Features, FAQ, testimonials sections @@ -56,7 +95,7 @@ Simple Mode is designed for internal organizational use where you want to hide a SIMPLE_MODE=true npm run build # Or use the pre-built Docker image -docker run -p 3000:8080 bentopdf/bentopdf-simple:latest +docker run -p 3000:8080 bentopdfteam/bentopdf-simple:latest ``` See [SIMPLE_MODE.md](https://github.com/alam00000/bentopdf/blob/main/SIMPLE_MODE.md) for full details. @@ -69,6 +108,36 @@ Deploy to a subdirectory: BASE_URL=/pdf-tools/ npm run build ``` +### Custom Branding + +Replace the default BentoPDF logo, name, and footer text with your own at build time: + +| Variable | Description | Default | +| ------------------ | ------------------------------------- | --------------------------------------- | +| `VITE_BRAND_NAME` | Brand name shown in header and footer | `BentoPDF` | +| `VITE_BRAND_LOGO` | Logo path relative to `public/` | `images/favicon-no-bg.svg` | +| `VITE_FOOTER_TEXT` | Custom footer/copyright text | `© 2026 BentoPDF. All rights reserved.` | + +```bash +# Place your logo in public/, then build +VITE_BRAND_NAME="AcmePDF" \ +VITE_BRAND_LOGO="images/acme-logo.svg" \ +VITE_FOOTER_TEXT="© 2026 Acme Corp. Internal use only." \ +npm run build +``` + +Or via Docker: + +```bash +docker build \ + --build-arg VITE_BRAND_NAME="AcmePDF" \ + --build-arg VITE_BRAND_LOGO="images/acme-logo.svg" \ + --build-arg VITE_FOOTER_TEXT="© 2026 Acme Corp. Internal use only." \ + -t acmepdf . +``` + +Branding works in both full mode and Simple Mode, and can be combined with all other build-time options (`BASE_URL`, `SIMPLE_MODE`, `VITE_DEFAULT_LANGUAGE`). + ## Deployment Guides Choose your platform: @@ -81,15 +150,249 @@ Choose your platform: - [Nginx](/self-hosting/nginx) - [Apache](/self-hosting/apache) - [Docker](/self-hosting/docker) +- [Kubernetes](/self-hosting/kubernetes) - [CORS Proxy](/self-hosting/cors-proxy) - Required for digital signatures +## WASM Configuration (AGPL Components) + +BentoPDF **does not bundle** AGPL-licensed processing libraries in its source code, but **pre-configures CDN URLs** so all features work out of the box — no manual setup needed. + +::: tip Zero-Config by Default +As of v2.0.0, WASM modules are pre-configured to load from jsDelivr CDN via environment variables. All advanced features work immediately without any user configuration. +::: + +| Component | License | Features | +| --------------- | -------- | ---------------------------------------------------------------- | +| **PyMuPDF** | AGPL-3.0 | EPUB/MOBI/FB2/XPS conversion, image extraction, table extraction | +| **Ghostscript** | AGPL-3.0 | PDF/A conversion, compression, deskewing, rasterization | +| **CoherentPDF** | AGPL-3.0 | Table of contents, attachments, PDF merge with bookmarks | + +### Default Environment Variables + +These are set in `.env.production` and baked into the build: + +```bash +VITE_WASM_PYMUPDF_URL=https://cdn.jsdelivr.net/npm/@bentopdf/pymupdf-wasm@0.11.16/ +VITE_WASM_GS_URL=https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm/assets/ +VITE_WASM_CPDF_URL=https://cdn.jsdelivr.net/npm/coherentpdf/dist/ +VITE_TESSERACT_WORKER_URL= +VITE_TESSERACT_CORE_URL= +VITE_TESSERACT_LANG_URL= +VITE_TESSERACT_AVAILABLE_LANGUAGES= +VITE_OCR_FONT_BASE_URL= +``` + +### Overriding WASM URLs + +You can override the defaults at build time for custom deployments: + +```bash +# Via Docker build args +docker build \ + --build-arg VITE_WASM_PYMUPDF_URL=https://your-server.com/pymupdf/ \ + --build-arg VITE_WASM_GS_URL=https://your-server.com/gs/ \ + --build-arg VITE_WASM_CPDF_URL=https://your-server.com/cpdf/ \ + --build-arg VITE_TESSERACT_WORKER_URL=https://your-server.com/ocr/worker.min.js \ + --build-arg VITE_TESSERACT_CORE_URL=https://your-server.com/ocr/core \ + --build-arg VITE_TESSERACT_LANG_URL=https://your-server.com/ocr/lang-data \ + --build-arg VITE_TESSERACT_AVAILABLE_LANGUAGES=eng,deu \ + --build-arg VITE_OCR_FONT_BASE_URL=https://your-server.com/ocr/fonts \ + -t bentopdf . + +# Or via .env.production before building from source +VITE_WASM_PYMUPDF_URL=https://your-server.com/pymupdf/ npm run build +``` + +To disable a module entirely (require manual user config via Advanced Settings), set its variable to an empty string. + +For OCR, either leave all `VITE_TESSERACT_*` variables empty and keep the default online assets, or set the worker/core/lang URLs together for self-hosted/offline OCR. If you bundle only specific OCR languages, also set `VITE_TESSERACT_AVAILABLE_LANGUAGES` to the same comma-separated codes so the UI only offers installed languages and unsupported selections fail with a descriptive error. For fully offline searchable-PDF output, also set `VITE_OCR_FONT_BASE_URL` to the internal directory that serves the bundled OCR fonts. + +Users can also override these defaults at any time via **Advanced Settings** in the UI — user overrides stored in the browser take priority over environment defaults. + +### Air-Gapped / Offline Deployment + +For networks with no internet access (government, healthcare, financial, etc.). The WASM URLs are baked into the JavaScript at **build time** — the actual WASM files are downloaded by the **user's browser** at runtime. So you need to prepare everything on a machine with internet, then transfer it into the isolated network. + +#### Automated Script (Recommended) + +The included `prepare-airgap.sh` script automates the entire process — downloading WASM packages, building the Docker image, and producing a self-contained bundle with a setup script. + +```bash +git clone https://github.com/alam00000/bentopdf.git +cd bentopdf + +# Show supported OCR language codes (for --ocr-languages) +bash scripts/prepare-airgap.sh --list-ocr-languages + +# Search OCR language codes by name or abbreviation +bash scripts/prepare-airgap.sh --search-ocr-language german + +# Interactive mode — prompts for all options +bash scripts/prepare-airgap.sh + +# Or fully automated +bash scripts/prepare-airgap.sh --wasm-base-url https://internal.example.com/wasm +``` + +This produces a bundle directory: + +``` +bentopdf-airgap-bundle/ + bentopdf.tar # Docker image + *.tgz # WASM packages (PyMuPDF, Ghostscript, CoherentPDF, Tesseract) + tesseract-langdata/ # OCR traineddata files + ocr-fonts/ # OCR text-layer font files + setup.sh # Setup script for the air-gapped side + README.md # Instructions +``` + +Transfer the bundle into the air-gapped network via USB, internal artifact repo, or approved method. Then run the included setup script: + +```bash +cd bentopdf-airgap-bundle +bash setup.sh +``` + +The setup script loads the Docker image, extracts WASM files, and optionally starts the container. + +**Script options:** + +| Flag | Description | Default | +| ------------------------------ | ------------------------------------------------ | --------------------------------- | +| `--wasm-base-url ` | Where WASMs will be hosted internally | _(required, prompted if missing)_ | +| `--image-name ` | Docker image tag | `bentopdf` | +| `--output-dir ` | Output bundle directory | `./bentopdf-airgap-bundle` | +| `--simple-mode` | Enable Simple Mode | off | +| `--base-url ` | Subdirectory base URL (e.g. `/pdf/`) | `/` | +| `--language ` | Default UI language (e.g. `fr`, `de`) | _(none)_ | +| `--brand-name ` | Custom brand name | _(none)_ | +| `--brand-logo ` | Logo path relative to `public/` | _(none)_ | +| `--footer-text ` | Custom footer text | _(none)_ | +| `--ocr-languages ` | Comma-separated OCR languages to bundle | `eng` | +| `--list-ocr-languages` | Print supported OCR codes and names, then exit | off | +| `--search-ocr-language ` | Search OCR codes by name or abbreviation | off | +| `--dockerfile ` | Dockerfile to use | `Dockerfile` | +| `--skip-docker` | Skip Docker build and export | off | +| `--skip-wasm` | Skip WASM download (reuse existing `.tgz` files) | off | + +The interactive prompt also accepts `list` to print the full supported Tesseract code list and `search ` to find matches such as `search german` or `search chi`. + +::: warning Same-Origin Requirement +WASM files must be served from the **same origin** as the BentoPDF app. Web Workers use `importScripts()` which cannot load scripts cross-origin. For example, if BentoPDF runs at `https://internal.example.com`, the WASM base URL should also be `https://internal.example.com/wasm`. +::: + +#### Manual Steps + +
+If you prefer to do it manually without the script + +**Step 1: Download the WASM and OCR packages** (on a machine with internet) + +```bash +npm pack @bentopdf/pymupdf-wasm@0.11.14 +npm pack @bentopdf/gs-wasm +npm pack coherentpdf +npm pack tesseract.js@7.0.0 +npm pack tesseract.js-core@7.0.0 +mkdir -p tesseract-langdata +curl -fsSL https://cdn.jsdelivr.net/npm/@tesseract.js-data/eng/4.0.0_best_int/eng.traineddata.gz -o tesseract-langdata/eng.traineddata.gz +mkdir -p ocr-fonts +curl -fsSL https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSans/NotoSans-Regular.ttf -o ocr-fonts/NotoSans-Regular.ttf +``` + +**Step 2: Build the Docker image with internal URLs** + +```bash +git clone https://github.com/alam00000/bentopdf.git +cd bentopdf + +docker build \ + --build-arg VITE_WASM_PYMUPDF_URL=https://internal-server.example.com/wasm/pymupdf/ \ + --build-arg VITE_WASM_GS_URL=https://internal-server.example.com/wasm/gs/ \ + --build-arg VITE_WASM_CPDF_URL=https://internal-server.example.com/wasm/cpdf/ \ + --build-arg VITE_TESSERACT_WORKER_URL=https://internal-server.example.com/wasm/ocr/worker.min.js \ + --build-arg VITE_TESSERACT_CORE_URL=https://internal-server.example.com/wasm/ocr/core \ + --build-arg VITE_TESSERACT_LANG_URL=https://internal-server.example.com/wasm/ocr/lang-data \ + --build-arg VITE_OCR_FONT_BASE_URL=https://internal-server.example.com/wasm/ocr/fonts \ + -t bentopdf . +``` + +**Step 3: Export the Docker image** + +```bash +docker save bentopdf -o bentopdf.tar +``` + +**Step 4: Transfer into the air-gapped network** + +Copy via USB, internal artifact repo, or approved transfer method: + +- `bentopdf.tar` — the Docker image +- The five `.tgz` WASM/OCR packages from Step 1 +- The `tesseract-langdata/` directory from Step 1 +- The `ocr-fonts/` directory from Step 1 + +**Step 5: Set up inside the air-gapped network** + +```bash +# Load the Docker image +docker load -i bentopdf.tar + +# Extract WASM packages +mkdir -p ./wasm/pymupdf ./wasm/gs ./wasm/cpdf ./wasm/ocr/core ./wasm/ocr/lang-data ./wasm/ocr/fonts +tar xzf bentopdf-pymupdf-wasm-0.11.14.tgz -C ./wasm/pymupdf --strip-components=1 +tar xzf bentopdf-gs-wasm-*.tgz -C ./wasm/gs --strip-components=1 +tar xzf coherentpdf-*.tgz -C ./wasm/cpdf --strip-components=1 +TEMP_TESS=$(mktemp -d) +tar xzf tesseract.js-7.0.0.tgz -C "$TEMP_TESS" +cp "$TEMP_TESS/package/dist/worker.min.js" ./wasm/ocr/worker.min.js +rm -rf "$TEMP_TESS" +tar xzf tesseract.js-core-7.0.0.tgz -C ./wasm/ocr/core --strip-components=1 +cp ./tesseract-langdata/*.traineddata.gz ./wasm/ocr/lang-data/ +cp ./ocr-fonts/* ./wasm/ocr/fonts/ + +# Run BentoPDF +docker run -d -p 3000:8080 --restart unless-stopped bentopdf +``` + +Make sure the files are accessible at the URLs you configured in Step 2, including `.../ocr/worker.min.js`, `.../ocr/core`, `.../ocr/lang-data`, and `.../ocr/fonts`. + +
+ +::: info Building from source instead of Docker? +Set the variables in `.env.production` before running `npm run build`: + +```bash +VITE_WASM_PYMUPDF_URL=https://internal-server.example.com/wasm/pymupdf/ +VITE_WASM_GS_URL=https://internal-server.example.com/wasm/gs/ +VITE_WASM_CPDF_URL=https://internal-server.example.com/wasm/cpdf/ +VITE_TESSERACT_WORKER_URL=https://internal-server.example.com/wasm/ocr/worker.min.js +VITE_TESSERACT_CORE_URL=https://internal-server.example.com/wasm/ocr/core +VITE_TESSERACT_LANG_URL=https://internal-server.example.com/wasm/ocr/lang-data +VITE_OCR_FONT_BASE_URL=https://internal-server.example.com/wasm/ocr/fonts +``` + +::: + +### Hosting Your Own WASM Proxy + +If you need to serve AGPL WASM files with proper CORS headers, you can deploy a simple proxy. See the [Cloudflare WASM Proxy guide](https://github.com/alam00000/bentopdf/blob/main/cloudflare/WASM-PROXY.md) for an example implementation. + +::: tip Why Separate? +This separation ensures: + +- Clear legal compliance for commercial users +- BentoPDF's core remains under its dual-license (AGPL-3.0 / Commercial) +- WASM files are loaded at runtime, not bundled in the source + ::: + ## System Requirements -| Requirement | Minimum | -|-------------|---------| -| Storage | ~500 MB (with all WASM modules) | -| RAM | 512 MB | -| CPU | Any modern processor | +| Requirement | Minimum | +| ----------- | ----------------------------------- | +| Storage | ~100 MB (core without AGPL modules) | +| RAM | 512 MB | +| CPU | Any modern processor | ::: tip BentoPDF is a static site—there's no database or backend server required! diff --git a/docs/self-hosting/kubernetes.md b/docs/self-hosting/kubernetes.md new file mode 100644 index 0000000..183ad9e --- /dev/null +++ b/docs/self-hosting/kubernetes.md @@ -0,0 +1,157 @@ +# Deploy with Kubernetes + +Kubernetes may be overkill for a static site, but it can be a great fit if you already standardize on Helm + GitOps. + +> [!IMPORTANT] +> **Required Headers for Office File Conversion** +> +> LibreOffice-based tools (Word, Excel, PowerPoint conversion) require these HTTP headers for `SharedArrayBuffer` support: +> - `Cross-Origin-Opener-Policy: same-origin` +> - `Cross-Origin-Embedder-Policy: require-corp` +> +> The official BentoPDF nginx images include these headers. In Kubernetes, **Ingress/Gateway controllers are also reverse proxies**, so ensure these headers are preserved (or add them at the edge). + +## Prereqs + +- Kubernetes cluster +- Helm v3 +- A BentoPDF nginx image (e.g. `ghcr.io/alam00000/bentopdf:`) that serves on **port 8080** + +## Deploy with Helm + +### Install from this repo (local chart) + +```bash +kubectl create namespace bentopdf + +helm upgrade --install bentopdf /path/to/bentopdf/chart \ + --namespace bentopdf \ + --set image.repository=ghcr.io/alam00000/bentopdf \ + --set image.tag=latest +``` + +### Install from GHCR (OCI chart) + +If the chart is published to GHCR as an OCI artifact: + +```bash +export GHCR_USERNAME="" + +helm upgrade --install bentopdf oci://ghcr.io/$GHCR_USERNAME/charts/bentopdf \ + --namespace bentopdf \ + --create-namespace \ + --version 0.1.0 \ + --set image.repository=ghcr.io/alam00000/bentopdf \ + --set image.tag=latest +``` + +## Expose it + +### Port-forward (quick test) + +```bash +kubectl -n bentopdf port-forward deploy/bentopdf 8080:8080 +``` + +### Ingress (optional) + +Enable Ingress (example for nginx-ingress): + +```yaml +ingress: + enabled: true + className: nginx + hosts: + - host: pdf.example.com + paths: + - path: / + pathType: Prefix +``` + +### Gateway API (optional) + +This chart supports Gateway API `Gateway` + `HTTPRoute`. + +Example (Cloudflare Gateway API operator): + +```yaml +gateway: + enabled: true + name: bento-tunnel + namespace: bentopdf + gatewayClassName: cloudflare + +httpRoute: + enabled: true + parentRefs: + - name: bento-tunnel + namespace: bentopdf + sectionName: http + hostnames: + - pdfs.example.com +``` + +## Ensuring the SharedArrayBuffer headers still work (Ingress/Gateway) + +### What "should" happen + +BentoPDF’s nginx config sets the required response headers. Most Ingress/Gateway controllers **pass upstream response headers through unchanged**. + +### What can break it + +- A controller/edge policy that **overrides** or **strips** response headers +- A "security headers" middleware that sets different COOP/COEP values + +### How to verify + +Run this against your public endpoint: + +```bash +curl -I https://pdf.example.com/ | egrep -i 'cross-origin-opener-policy|cross-origin-embedder-policy' +``` + +You should see: + +- `Cross-Origin-Opener-Policy: same-origin` +- `Cross-Origin-Embedder-Policy: require-corp` + +### If your Ingress controller does not preserve them + +Add the headers at the edge (controller-specific). Example for **nginx-ingress**: + +```yaml +ingress: + enabled: true + className: nginx + annotations: + nginx.ingress.kubernetes.io/configuration-snippet: | + add_header Cross-Origin-Opener-Policy "same-origin" always; + add_header Cross-Origin-Embedder-Policy "require-corp" always; +``` + +### If you’re using Gateway API and want to force-add headers + +Gateway API supports a `ResponseHeaderModifier` filter. You can attach it in `httpRoute.rules[*].filters`: + +```yaml +httpRoute: + enabled: true + hostnames: [pdf.example.com] + parentRefs: + - name: bento-tunnel + namespace: misc + sectionName: http + rules: + - matches: + - path: { type: PathPrefix, value: / } + filters: + - type: ResponseHeaderModifier + responseHeaderModifier: + set: + - name: Cross-Origin-Opener-Policy + value: same-origin + - name: Cross-Origin-Embedder-Policy + value: require-corp +``` + +Support for specific filters depends on your Gateway controller; if a filter is ignored, add headers at the edge/controller layer instead. diff --git a/docs/self-hosting/netlify.md b/docs/self-hosting/netlify.md index 910fa23..c058566 100644 --- a/docs/self-hosting/netlify.md +++ b/docs/self-hosting/netlify.md @@ -17,11 +17,11 @@ ### Step 2: Configure Build Settings -| Setting | Value | -|---------|-------| -| Build command | `npm run build` | -| Publish directory | `dist` | -| Node version | 18+ | +| Setting | Value | +| ----------------- | --------------- | +| Build command | `npm run build` | +| Publish directory | `dist` | +| Node version | 18+ | ### Step 3: Deploy @@ -39,27 +39,61 @@ Create `netlify.toml` in your project root: [build.environment] NODE_VERSION = "18" -# SPA routing -[[redirects]] - from = "/*" - to = "/index.html" - status = 200 +# Required security headers for SharedArrayBuffer (used by LibreOffice WASM) +[[headers]] + for = "/*" + [headers.values] + Cross-Origin-Embedder-Policy = "require-corp" + Cross-Origin-Opener-Policy = "same-origin" + Cross-Origin-Resource-Policy = "cross-origin" -# Cache WASM files +# Pre-compressed LibreOffice WASM binary - must be served with Content-Encoding +[[headers]] + for = "/libreoffice-wasm/soffice.wasm.gz" + [headers.values] + Content-Type = "application/wasm" + Content-Encoding = "gzip" + Cache-Control = "public, max-age=31536000, immutable" + +# Pre-compressed LibreOffice WASM data - must be served with Content-Encoding +[[headers]] + for = "/libreoffice-wasm/soffice.data.gz" + [headers.values] + Content-Type = "application/octet-stream" + Content-Encoding = "gzip" + Cache-Control = "public, max-age=31536000, immutable" + +# Cache other WASM files [[headers]] for = "*.wasm" [headers.values] Cache-Control = "public, max-age=31536000, immutable" Content-Type = "application/wasm" + +# SPA routing +[[redirects]] + from = "/*" + to = "/index.html" + status = 200 ``` +::: warning Important +The `Cross-Origin-Embedder-Policy` and `Cross-Origin-Opener-Policy` headers are required for Word/ODT/Excel/PowerPoint to PDF conversions. Without them, `SharedArrayBuffer` is unavailable and the LibreOffice WASM engine will fail to initialize. + +The `Content-Encoding: gzip` headers on the `.wasm.gz` and `.data.gz` files tell the browser to decompress them automatically. Without these, the browser receives raw gzip bytes and WASM compilation fails. +::: + ## Environment Variables Set these in Site settings → Environment variables: -| Variable | Description | -|----------|-------------| -| `SIMPLE_MODE` | Set to `true` for minimal build | +| Variable | Description | +| ----------------------- | ----------------------------------------------------------- | +| `SIMPLE_MODE` | Set to `true` for minimal build | +| `VITE_BRAND_NAME` | Custom brand name (replaces "BentoPDF") | +| `VITE_BRAND_LOGO` | Logo path relative to `public/` (e.g. `images/my-logo.svg`) | +| `VITE_FOOTER_TEXT` | Custom footer/copyright text | +| `VITE_DEFAULT_LANGUAGE` | Default UI language (e.g. `fr`, `de`, `es`) | ## Custom Domain @@ -78,6 +112,19 @@ git lfs track "*.wasm" ## Troubleshooting +### Word/ODT/Excel to PDF Stuck at 55% + +If document conversions hang at 55%, open DevTools Console and check: + +```js +console.log(window.crossOriginIsolated); // should be true +console.log(typeof SharedArrayBuffer); // should be "function" +``` + +If `crossOriginIsolated` is `false`, the COEP/COOP headers from your `netlify.toml` are not being applied. Make sure the file is in your project root and redeploy. + +If you see `expected magic word 00 61 73 6d, found 1f 8b 08 08` in the console, the `.wasm.gz` files are missing `Content-Encoding: gzip` headers. Ensure the `[[headers]]` blocks for `soffice.wasm.gz` and `soffice.data.gz` are in your `netlify.toml`. + ### Build Fails Check Node version compatibility: diff --git a/docs/self-hosting/nginx.md b/docs/self-hosting/nginx.md index 35b0612..a0382b9 100644 --- a/docs/self-hosting/nginx.md +++ b/docs/self-hosting/nginx.md @@ -17,6 +17,12 @@ npm install npm run build ``` +To customize branding, set environment variables before building: + +```bash +VITE_BRAND_NAME="AcmePDF" VITE_BRAND_LOGO="images/acme-logo.svg" npm run build +``` + ## Step 2: Copy Files ```bash @@ -57,21 +63,51 @@ server { application/wasm wasm; } - # Cache static assets - location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|wasm)$ { - expires 1y; - add_header Cache-Control "public, immutable"; - } - - # SPA routing - serve index.html for all routes - location / { - try_files $uri $uri/ /index.html; - } + # Required headers for SharedArrayBuffer (LibreOffice WASM) + # These must be on every response - especially HTML pages + add_header Cross-Origin-Embedder-Policy "require-corp" always; + add_header Cross-Origin-Opener-Policy "same-origin" always; + add_header Cross-Origin-Resource-Policy "cross-origin" always; # Security headers add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Content-Type-Options "nosniff" always; add_header X-XSS-Protection "1; mode=block" always; + + # Pre-compressed LibreOffice WASM binary + location ~* /libreoffice-wasm/soffice\.wasm\.gz$ { + gzip off; + types {} default_type application/wasm; + add_header Content-Encoding gzip; + add_header Cache-Control "public, immutable"; + add_header Cross-Origin-Embedder-Policy "require-corp" always; + add_header Cross-Origin-Opener-Policy "same-origin" always; + } + + # Pre-compressed LibreOffice WASM data + location ~* /libreoffice-wasm/soffice\.data\.gz$ { + gzip off; + types {} default_type application/octet-stream; + add_header Content-Encoding gzip; + add_header Cache-Control "public, immutable"; + add_header Cross-Origin-Embedder-Policy "require-corp" always; + add_header Cross-Origin-Opener-Policy "same-origin" always; + } + + # Cache static assets + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|wasm)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + add_header Cross-Origin-Embedder-Policy "require-corp" always; + add_header Cross-Origin-Opener-Policy "same-origin" always; + } + + # SPA routing - serve index.html for all routes + location / { + try_files $uri $uri/ /index.html; + add_header Cross-Origin-Embedder-Policy "require-corp" always; + add_header Cross-Origin-Opener-Policy "same-origin" always; + } } ``` @@ -120,7 +156,7 @@ http { # Increase buffer sizes client_max_body_size 100M; - + # Worker connections worker_connections 2048; } @@ -138,6 +174,18 @@ types { } ``` +### Word/ODT/Excel to PDF Not Working + +LibreOffice WASM requires `SharedArrayBuffer`, which needs `Cross-Origin-Embedder-Policy` and `Cross-Origin-Opener-Policy` headers. It also needs a secure context, so `http://localhost` works for local testing but `http://192.168.x.x` or other LAN IPs usually require HTTPS. Note that nginx `add_header` directives in a `location` block **override** server-level `add_header` directives — they don't merge. Every `location` block with its own `add_header` must include the COEP/COOP headers. + +Verify with: + +```bash +curl -I https://your-domain.com/ | grep -i cross-origin +``` + +If using a reverse proxy in front of nginx, ensure it preserves these headers. + ### 502 Bad Gateway Check Nginx error logs: diff --git a/docs/self-hosting/vercel.md b/docs/self-hosting/vercel.md index ed0b9e5..e8d55da 100644 --- a/docs/self-hosting/vercel.md +++ b/docs/self-hosting/vercel.md @@ -18,20 +18,24 @@ Fork [bentopdf/bentopdf](https://github.com/alam00000/bentopdf) to your GitHub a 2. Select your forked repository 3. Configure the project: -| Setting | Value | -|---------|-------| -| Framework Preset | Vite | -| Build Command | `npm run build` | -| Output Directory | `dist` | -| Install Command | `npm install` | +| Setting | Value | +| ---------------- | --------------- | +| Framework Preset | Vite | +| Build Command | `npm run build` | +| Output Directory | `dist` | +| Install Command | `npm install` | ### Step 3: Environment Variables (Optional) Add these if needed: -``` -SIMPLE_MODE=false -``` +| Variable | Description | +| ----------------------- | ----------------------------------------------------------- | +| `SIMPLE_MODE` | Set to `true` for minimal UI | +| `VITE_BRAND_NAME` | Custom brand name (replaces "BentoPDF") | +| `VITE_BRAND_LOGO` | Logo path relative to `public/` (e.g. `images/my-logo.svg`) | +| `VITE_FOOTER_TEXT` | Custom footer/copyright text | +| `VITE_DEFAULT_LANGUAGE` | Default UI language (e.g. `fr`, `de`, `es`) | ### Step 4: Deploy @@ -73,3 +77,46 @@ Add a `vercel.json` for SPA routing: "rewrites": [{ "source": "/(.*)", "destination": "/index.html" }] } ``` + +### Word/ODT/Excel to PDF Not Working + +LibreOffice WASM conversions require `SharedArrayBuffer`, which needs these response headers on all pages: + +- `Cross-Origin-Embedder-Policy: require-corp` +- `Cross-Origin-Opener-Policy: same-origin` + +The pre-compressed `.wasm.gz` and `.data.gz` files also need `Content-Encoding: gzip` so the browser decompresses them. + +Add these to your `vercel.json`: + +```json +{ + "rewrites": [{ "source": "/(.*)", "destination": "/index.html" }], + "headers": [ + { + "source": "/(.*)", + "headers": [ + { "key": "Cross-Origin-Embedder-Policy", "value": "require-corp" }, + { "key": "Cross-Origin-Opener-Policy", "value": "same-origin" }, + { "key": "Cross-Origin-Resource-Policy", "value": "cross-origin" } + ] + }, + { + "source": "/libreoffice-wasm/soffice.wasm.gz", + "headers": [ + { "key": "Content-Type", "value": "application/wasm" }, + { "key": "Content-Encoding", "value": "gzip" } + ] + }, + { + "source": "/libreoffice-wasm/soffice.data.gz", + "headers": [ + { "key": "Content-Type", "value": "application/octet-stream" }, + { "key": "Content-Encoding", "value": "gzip" } + ] + } + ] +} +``` + +To verify, open DevTools Console and run `console.log(window.crossOriginIsolated)` — it should return `true`. diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..0a26ae5 --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,42 @@ +#!/bin/sh +set -e + +PUID=${PUID:-1000} +PGID=${PGID:-1000} + +# Validate PUID/PGID +case "$PUID" in + ''|*[!0-9]*) echo "ERROR: PUID must be a number, got '$PUID'" >&2; exit 1 ;; +esac +case "$PGID" in + ''|*[!0-9]*) echo "ERROR: PGID must be a number, got '$PGID'" >&2; exit 1 ;; +esac +if [ "$PUID" -eq 0 ] || [ "$PGID" -eq 0 ]; then + echo "ERROR: PUID/PGID cannot be 0 (root). Use the standard Dockerfile instead." >&2 + exit 1 +fi + +echo "Starting BentoPDF with PUID=$PUID PGID=$PGID" + +addgroup -g "$PGID" bentopdf 2>/dev/null || true +adduser -u "$PUID" -G bentopdf -D -H -s /sbin/nologin bentopdf 2>/dev/null || true + +rm -f /var/log/nginx/error.log /var/log/nginx/access.log +touch /var/log/nginx/error.log /var/log/nginx/access.log +chown "$PUID:$PGID" /var/log/nginx /var/log/nginx/error.log /var/log/nginx/access.log + +sed -i '1i error_log stderr warn;' /etc/nginx/nginx.conf +sed -i '/^http {/a\ access_log /var/log/nginx/access.log;' /etc/nginx/nginx.conf + +chown -R "$PUID:$PGID" \ + /etc/nginx/tmp \ + /var/cache/nginx \ + /usr/share/nginx/html \ + /etc/nginx/nginx.conf + +if [ "$DISABLE_IPV6" = "true" ]; then + echo "Disabling Nginx IPv6 listener" + sed -i '/^[[:space:]]*listen[[:space:]]*\[::\]:[0-9]*/s/^/#/' /etc/nginx/nginx.conf +fi + +exec su-exec "$PUID:$PGID" "$@" diff --git a/index.html b/index.html index b2c6ddc..516c13b 100644 --- a/index.html +++ b/index.html @@ -209,6 +209,21 @@ + + + DigitalOcean Referral Badge + @@ -701,6 +716,60 @@ > + +
+
+ +

+ Display tools as a compact list instead of cards +

+
+ +
+ + + +
+ + Advanced Settings + +

+ Configure external processing modules (PyMuPDF, Ghostscript, + CoherentPDF) +

+
+ +
diff --git a/licensing.html b/licensing.html index 222db72..3654199 100644 --- a/licensing.html +++ b/licensing.html @@ -989,72 +989,6 @@

-
-

- - Important Notice on Third-Party Components -

-

- This software includes components licensed under the - GNU AGPL v3, including: -

-
    -
  • - CPDF -
  • -
  • - PyMuPDF -
  • -
  • - Ghostscript -
  • -
-
    -
  • - - This commercial license - does not grant rights to - use AGPL components in a closed-source manner. -
  • -
  • - - Users must comply with the AGPL v3 terms for these - components. -
  • -
  • - - Source code for all AGPL components is included in the - distribution. -
  • -
-
-

+ +
+

+ + AGPL Components - Not Bundled +

+

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

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

+ To enable features powered by these libraries: +

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

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

+
diff --git a/nginx.conf b/nginx.conf index d832721..25615f2 100644 --- a/nginx.conf +++ b/nginx.conf @@ -25,7 +25,23 @@ http { server_name localhost; root /usr/share/nginx/html; index index.html; - + absolute_redirect off; + + location ~ ^/(en|ar|be|da|de|es|fr|id|it|nl|pt|tr|vi|zh|zh-TW)(/.*)?$ { + try_files $uri $uri/ $uri.html /$1/index.html /index.html; + expires 5m; + add_header Cache-Control "public, must-revalidate"; + add_header Cross-Origin-Embedder-Policy "require-corp" always; + add_header Cross-Origin-Opener-Policy "same-origin" always; + } + + location ~ ^/(.+?)/(en|ar|be|da|de|es|fr|id|it|nl|pt|tr|vi|zh|zh-TW)(/.*)?$ { + try_files $uri $uri/ $uri.html /$1/$2/index.html /$1/index.html /index.html; + expires 5m; + add_header Cache-Control "public, must-revalidate"; + add_header Cross-Origin-Embedder-Policy "require-corp" always; + add_header Cross-Origin-Opener-Policy "same-origin" always; + } location ~* \.html$ { expires 1h; @@ -83,22 +99,6 @@ http { add_header Cache-Control "public, immutable"; } - location ~ ^/(en|de|es|zh|zh-TW|vi|it|id|tr|fr|pt)(/.*)?$ { - try_files $uri $uri/ $uri.html /$1/index.html /index.html; - expires 5m; - add_header Cache-Control "public, must-revalidate"; - add_header Cross-Origin-Embedder-Policy "require-corp" always; - add_header Cross-Origin-Opener-Policy "same-origin" always; - } - - location ~ ^/(.+?)/(en|de|es|zh|zh-TW|vi|it|id|tr|fr|pt)(/.*)?$ { - try_files $uri $uri/ $uri.html /$1/$2/index.html /$1/index.html /index.html; - expires 5m; - add_header Cache-Control "public, must-revalidate"; - add_header Cross-Origin-Embedder-Policy "require-corp" always; - add_header Cross-Origin-Opener-Policy "same-origin" always; - } - location / { try_files $uri $uri/ $uri.html /index.html; expires 5m; diff --git a/package-lock.json b/package-lock.json index 80dae04..ff516b9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,14 @@ { "name": "bento-pdf", - "version": "1.15.4", + "version": "2.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "bento-pdf", - "version": "1.15.4", + "version": "2.5.0", "license": "AGPL-3.0-only", "dependencies": { - "@bentopdf/gs-wasm": "^0.1.0", - "@bentopdf/pymupdf-wasm": "^0.11.12", "@fontsource/cedarville-cursive": "^5.2.7", "@fontsource/dancing-script": "^5.2.8", "@fontsource/dm-sans": "^5.2.8", @@ -18,30 +16,34 @@ "@fontsource/kalam": "^5.2.8", "@fontsource/lato": "^5.2.7", "@fontsource/merriweather": "^5.2.11", - "@kenjiuno/msgreader": "^1.27.1-alpha.1", - "@matbee/libreoffice-converter": "^2.3.1", + "@kenjiuno/msgreader": "^1.28.0", + "@matbee/libreoffice-converter": "^2.5.0", "@neslinesli93/qpdf-wasm": "^0.3.0", "@pdf-lib/fontkit": "^1.1.1", "@phosphor-icons/web": "^2.1.2", - "@tailwindcss/vite": "^4.1.15", + "@retejs/lit-plugin": "^2.0.7", + "@tailwindcss/vite": "^4.2.1", "@types/markdown-it": "^14.1.2", "@types/node-forge": "^1.3.14", "@types/papaparse": "^5.5.2", "archiver": "^7.0.1", "blob-stream": "^0.1.3", - "cropperjs": "^1.6.1", - "embedpdf-snippet": "file:vendor/embedpdf/embedpdf-snippet-1.5.0.tgz", + "bwip-js": "^4.8.0", + "cropperjs": "^1.6.2", + "diff": "^8.0.3", + "embedpdf-snippet": "file:vendor/embedpdf/embedpdf-snippet-2.3.0.tgz", "heic2any": "^0.0.4", "highlight.js": "^11.11.1", "html2canvas": "^1.4.1", - "i18next": "^25.7.2", - "i18next-browser-languagedetector": "^8.2.0", + "i18next": "^25.8.13", + "i18next-browser-languagedetector": "^8.2.1", "i18next-http-backend": "^3.0.2", - "jspdf": "^4.0.0", + "jspdf": "^4.2.0", "jspdf-autotable": "^5.0.2", "jszip": "^3.10.1", - "lucide": "^0.546.0", - "markdown-it": "^14.1.0", + "lit": "^3.3.2", + "lucide": "^0.575.0", + "markdown-it": "^14.1.1", "markdown-it-abbr": "^2.0.0", "markdown-it-anchor": "^9.2.0", "markdown-it-deflist": "^3.0.0", @@ -53,55 +55,63 @@ "markdown-it-sup": "^2.0.0", "markdown-it-task-lists": "^2.1.1", "markdown-it-toc-done-right": "^4.2.0", - "mermaid": "^11.12.2", + "mermaid": "^11.12.3", + "microdiff": "^1.5.0", "node-forge": "^1.3.3", "papaparse": "^5.5.3", "pdf-lib": "^1.17.1", - "pdfjs-dist": "^5.4.296", + "pdfjs-dist": "^5.4.624", "pdfkit": "^0.17.2", - "postal-mime": "^2.7.1", - "sortablejs": "^1.15.6", + "pixelmatch": "^7.1.0", + "postal-mime": "^2.7.3", + "rete": "^2.0.6", + "rete-area-plugin": "^2.1.5", + "rete-connection-plugin": "^2.0.5", + "rete-engine": "^2.1.1", + "rete-render-utils": "^2.0.3", + "sortablejs": "^1.15.7", "tailwindcss": "^4.1.14", - "terser": "^5.44.0", - "tesseract.js": "^6.0.1", + "terser": "^5.46.0", + "tesseract.js": "^7.0.0", "tiff": "^7.1.2", "utif": "^3.1.0", - "vite-plugin-static-copy": "^3.1.4", - "xlsx": "file:vendor/sheetjs/xlsx-0.20.2.tgz", + "vite-plugin-static-copy": "^3.2.0", + "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz", "zgapdfsigner": "^2.7.5" }, "devDependencies": { - "@eslint/js": "^9.39.2", + "@eslint/js": "^10.0.1", "@testing-library/dom": "^10.4.1", "@types/blob-stream": "^0.1.33", "@types/html2canvas": "^1.0.0", - "@types/pdfkit": "^0.17.3", + "@types/pdfkit": "^0.17.5", "@types/sortablejs": "^1.15.8", "@types/utif": "^3.0.6", - "@vitest/coverage-v8": "^3.2.4", - "@vitest/ui": "^3.2.4", - "eslint": "^9.39.2", + "@vitejs/plugin-basic-ssl": "^2.1.4", + "@vitest/coverage-v8": "^4.0.18", + "@vitest/ui": "^4.0.18", + "eslint": "^10.0.2", "eslint-config-prettier": "^10.1.8", - "globals": "^17.0.0", + "globals": "^17.4.0", "husky": "^9.1.7", - "jsdom": "^27.0.1", - "lint-staged": "^16.2.7", - "prettier": "^3.6.2", + "jsdom": "^28.1.0", + "lint-staged": "^16.3.1", + "prettier": "^3.8.1", "typescript": "~5.9.3", - "typescript-eslint": "^8.51.0", - "vite": "^7.1.11", + "typescript-eslint": "^8.56.1", + "vite": "^7.3.1", "vite-plugin-compression": "^0.5.1", "vite-plugin-handlebars": "^2.0.0", - "vite-plugin-node-polyfills": "^0.24.0", + "vite-plugin-node-polyfills": "^0.25.0", "vitepress": "^1.6.4", - "vitest": "^3.2.4", - "vue": "^3.5.26" + "vitest": "^4.0.18", + "vue": "^3.5.29" } }, "node_modules/@acemir/cssom": { - "version": "0.9.30", - "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.30.tgz", - "integrity": "sha512-9CnlMCI0LmCIq0olalQqdWrJHPzm0/tw3gzOA9zJSgvFX7Xau3D24mAGa4BtwxwY69nsuJW6kQqqCzf/mEcQgg==", + "version": "0.9.31", + "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz", + "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==", "dev": true, "license": "MIT" }, @@ -266,7 +276,6 @@ "integrity": "sha512-ZsOJqu4HOG5BlvIFnMU0YKjQ9ZI6r3C31dg2jk5kMWPSdhJpYL9xa5hEe7aieE+707dXeMI4ej3diy6mXdZpgA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@algolia/client-common": "5.46.2", "@algolia/requester-browser-xhr": "5.46.2", @@ -364,20 +373,6 @@ "node": ">= 14.0.0" } }, - "node_modules/@ampproject/remapping": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", - "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/@antfu/install-pkg": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-1.1.0.tgz", @@ -392,23 +387,26 @@ } }, "node_modules/@asamuzakjp/css-color": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.1.tgz", - "integrity": "sha512-B0Hv6G3gWGMn0xKJ0txEi/jM5iFpT3MfDxmhZFb4W047GvytCf1DHQ1D69W3zHI4yWe2aTZAA0JnbMZ7Xc8DuQ==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.0.1.tgz", + "integrity": "sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==", "dev": true, "license": "MIT", "dependencies": { - "@csstools/css-calc": "^2.1.4", - "@csstools/css-color-parser": "^3.1.0", - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4", - "lru-cache": "^11.2.4" + "@csstools/css-calc": "^3.1.1", + "@csstools/css-color-parser": "^4.0.2", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.2.6" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, "node_modules/@asamuzakjp/dom-selector": { - "version": "6.7.6", - "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.7.6.tgz", - "integrity": "sha512-hBaJER6A9MpdG3WgdlOolHmbOYvSk46y7IQN/1+iqiCuUu6iWdQrs9DGKF8ocqsEqWujWf/V7b7vaDgiUmIvUg==", + "version": "6.8.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz", + "integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==", "dev": true, "license": "MIT", "dependencies": { @@ -416,7 +414,7 @@ "bidi-js": "^1.0.3", "css-tree": "^3.1.0", "is-potential-custom-element-name": "^1.0.1", - "lru-cache": "^11.2.4" + "lru-cache": "^11.2.6" } }, "node_modules/@asamuzakjp/nwsapi": { @@ -460,12 +458,12 @@ } }, "node_modules/@babel/parser": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", - "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", "license": "MIT", "dependencies": { - "@babel/types": "^7.28.5" + "@babel/types": "^7.29.0" }, "bin": { "parser": "bin/babel-parser.js" @@ -475,18 +473,18 @@ } }, "node_modules/@babel/runtime": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", - "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/types": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", - "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.27.1", @@ -506,86 +504,68 @@ "node": ">=18" } }, - "node_modules/@bentopdf/gs-wasm": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@bentopdf/gs-wasm/-/gs-wasm-0.1.0.tgz", - "integrity": "sha512-C71zxZW4R7Oa6fdya5leTh2VOZOxqH8IQlveh13OeuwZ2ulrovSi9629xTzAiIeeVKvDZma1Klxy4MuK65xe9w==", - "license": "AGPL-3.0", - "peer": true, - "dependencies": { - "@types/emscripten": "^1.39.10" - } - }, - "node_modules/@bentopdf/pymupdf-wasm": { - "version": "0.11.12", - "resolved": "https://registry.npmjs.org/@bentopdf/pymupdf-wasm/-/pymupdf-wasm-0.11.12.tgz", - "integrity": "sha512-AcSg7v7pVhYcH23qLDEj3yTABlGIkZULPmrvWHRtEyD5QMS0TWOLUq/c0ATO371PKVlI4jEUpCBUj+iBsFJwVQ==", - "license": "AGPL-3.0", - "peerDependencies": { - "@bentopdf/gs-wasm": "*" - } - }, "node_modules/@braintree/sanitize-url": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-7.1.1.tgz", "integrity": "sha512-i1L7noDNxtFyL5DmZafWy1wRVhGehQmzZaz1HiN5e7iylJMSZR7ekOV7NsIqa5qBldlLrsKv4HbgFUVlQrz8Mw==", "license": "MIT" }, - "node_modules/@chevrotain/cst-dts-gen": { - "version": "11.0.3", - "resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-11.0.3.tgz", - "integrity": "sha512-BvIKpRLeS/8UbfxXxgC33xOumsacaeCKAjAeLyOn7Pcp95HiRbrpl14S+9vaZLolnbssPIUuiUd8IvgkRyt6NQ==", - "license": "Apache-2.0", + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", "dependencies": { - "@chevrotain/gast": "11.0.3", - "@chevrotain/types": "11.0.3", - "lodash-es": "4.17.21" + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" } }, - "node_modules/@chevrotain/cst-dts-gen/node_modules/lodash-es": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", - "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", - "license": "MIT" + "node_modules/@chevrotain/cst-dts-gen": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-11.1.2.tgz", + "integrity": "sha512-XTsjvDVB5nDZBQB8o0o/0ozNelQtn2KrUVteIHSlPd2VAV2utEb6JzyCJaJ8tGxACR4RiBNWy5uYUHX2eji88Q==", + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/gast": "11.1.2", + "@chevrotain/types": "11.1.2", + "lodash-es": "4.17.23" + } }, "node_modules/@chevrotain/gast": { - "version": "11.0.3", - "resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-11.0.3.tgz", - "integrity": "sha512-+qNfcoNk70PyS/uxmj3li5NiECO+2YKZZQMbmjTqRI3Qchu8Hig/Q9vgkHpI3alNjr7M+a2St5pw5w5F6NL5/Q==", + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-11.1.2.tgz", + "integrity": "sha512-Z9zfXR5jNZb1Hlsd/p+4XWeUFugrHirq36bKzPWDSIacV+GPSVXdk+ahVWZTwjhNwofAWg/sZg58fyucKSQx5g==", "license": "Apache-2.0", "dependencies": { - "@chevrotain/types": "11.0.3", - "lodash-es": "4.17.21" + "@chevrotain/types": "11.1.2", + "lodash-es": "4.17.23" } }, - "node_modules/@chevrotain/gast/node_modules/lodash-es": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", - "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", - "license": "MIT" - }, "node_modules/@chevrotain/regexp-to-ast": { - "version": "11.0.3", - "resolved": "https://registry.npmjs.org/@chevrotain/regexp-to-ast/-/regexp-to-ast-11.0.3.tgz", - "integrity": "sha512-1fMHaBZxLFvWI067AVbGJav1eRY7N8DDvYCTwGBiE/ytKBgP8azTdgyrKyWZ9Mfh09eHWb5PgTSO8wi7U824RA==", + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/@chevrotain/regexp-to-ast/-/regexp-to-ast-11.1.2.tgz", + "integrity": "sha512-nMU3Uj8naWer7xpZTYJdxbAs6RIv/dxYzkYU8GSwgUtcAAlzjcPfX1w+RKRcYG8POlzMeayOQ/znfwxEGo5ulw==", "license": "Apache-2.0" }, "node_modules/@chevrotain/types": { - "version": "11.0.3", - "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-11.0.3.tgz", - "integrity": "sha512-gsiM3G8b58kZC2HaWR50gu6Y1440cHiJ+i3JUvcp/35JchYejb2+5MVeJK0iKThYpAa/P2PYFV4hoi44HD+aHQ==", + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-11.1.2.tgz", + "integrity": "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==", "license": "Apache-2.0" }, "node_modules/@chevrotain/utils": { - "version": "11.0.3", - "resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-11.0.3.tgz", - "integrity": "sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ==", + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-11.1.2.tgz", + "integrity": "sha512-4mudFAQ6H+MqBTfqLmU7G1ZwRzCLfJEooL/fsF6rCX5eePMbGhoy5n4g+G4vlh2muDcsCTJtL+uKbOzWxs5LHA==", "license": "Apache-2.0" }, "node_modules/@csstools/color-helpers": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", - "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", "dev": true, "funding": [ { @@ -599,13 +579,13 @@ ], "license": "MIT-0", "engines": { - "node": ">=18" + "node": ">=20.19.0" } }, "node_modules/@csstools/css-calc": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", - "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz", + "integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==", "dev": true, "funding": [ { @@ -619,17 +599,17 @@ ], "license": "MIT", "engines": { - "node": ">=18" + "node": ">=20.19.0" }, "peerDependencies": { - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4" + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" } }, "node_modules/@csstools/css-color-parser": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", - "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz", + "integrity": "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==", "dev": true, "funding": [ { @@ -643,21 +623,21 @@ ], "license": "MIT", "dependencies": { - "@csstools/color-helpers": "^5.1.0", - "@csstools/css-calc": "^2.1.4" + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.1.1" }, "engines": { - "node": ">=18" + "node": ">=20.19.0" }, "peerDependencies": { - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4" + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" } }, "node_modules/@csstools/css-parser-algorithms": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", - "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", "dev": true, "funding": [ { @@ -670,18 +650,17 @@ } ], "license": "MIT", - "peer": true, "engines": { - "node": ">=18" + "node": ">=20.19.0" }, "peerDependencies": { - "@csstools/css-tokenizer": "^3.0.4" + "@csstools/css-tokenizer": "^4.0.0" } }, "node_modules/@csstools/css-syntax-patches-for-csstree": { - "version": "1.0.22", - "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.22.tgz", - "integrity": "sha512-qBcx6zYlhleiFfdtzkRgwNC7VVoAwfK76Vmsw5t+PbvtdknO9StgRk7ROvq9so1iqbdW4uLIDAsXRsTfUrIoOw==", + "version": "1.0.28", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.28.tgz", + "integrity": "sha512-1NRf1CUBjnr3K7hu8BLxjQrKCxEe8FP/xmPTenAxCRZWVLbmGotkFvG9mfNpjA6k7Bw1bw4BilZq9cu19RA5pg==", "dev": true, "funding": [ { @@ -693,15 +672,12 @@ "url": "https://opencollective.com/csstools" } ], - "license": "MIT-0", - "engines": { - "node": ">=18" - } + "license": "MIT-0" }, "node_modules/@csstools/css-tokenizer": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", - "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", "dev": true, "funding": [ { @@ -714,9 +690,8 @@ } ], "license": "MIT", - "peer": true, "engines": { - "node": ">=18" + "node": ">=20.19.0" } }, "node_modules/@docsearch/css": { @@ -771,14 +746,13 @@ } }, "node_modules/@embedpdf/core": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@embedpdf/core/-/core-1.5.0.tgz", - "integrity": "sha512-Yrh9XoVaT8cUgzgqpJ7hx5wg6BqQrCFirqqlSwVb+Ly9oNn4fZbR9GycIWmzJOU5XBnaOJjXfQSaDyoNP0woNA==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@embedpdf/core/-/core-2.3.0.tgz", + "integrity": "sha512-aPD7lNSCOLc5Nos9xGA3qAT5jFZdrTT7IVcpxtM1BOKa1FI0XmotJ8vgzcRxH/FLwUASC4xwR9QxzTKp2aLsZQ==", "license": "MIT", - "peer": true, "dependencies": { - "@embedpdf/engines": "1.5.0", - "@embedpdf/models": "1.5.0" + "@embedpdf/engines": "2.3.0", + "@embedpdf/models": "2.3.0" }, "peerDependencies": { "preact": "^10.26.4", @@ -789,13 +763,20 @@ } }, "node_modules/@embedpdf/engines": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@embedpdf/engines/-/engines-1.5.0.tgz", - "integrity": "sha512-/GzhjHFHWfOaX7vjgFJX/pyq668wYjoda1bZ9MpwF/EF000Wwy2Q0AOhprjldPFz8ASKjwKwqsXmaqrK99yOAQ==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@embedpdf/engines/-/engines-2.3.0.tgz", + "integrity": "sha512-QxNY58E2HgNgnbsTt5TnDUNvKoyabkf5IniGsiN5+rx6f4SFDpCnz3h1VJxNReWDyn9e16QlkUfgXX0qQWd3iQ==", "license": "MIT", "dependencies": { - "@embedpdf/models": "1.5.0", - "@embedpdf/pdfium": "1.5.0" + "@embedpdf/fonts-arabic": "1.0.0", + "@embedpdf/fonts-hebrew": "1.0.0", + "@embedpdf/fonts-jp": "1.0.0", + "@embedpdf/fonts-kr": "1.0.0", + "@embedpdf/fonts-latin": "1.0.0", + "@embedpdf/fonts-sc": "1.0.0", + "@embedpdf/fonts-tc": "1.0.0", + "@embedpdf/models": "2.3.0", + "@embedpdf/pdfium": "2.3.0" }, "peerDependencies": { "preact": "^10.26.4", @@ -805,81 +786,161 @@ "vue": ">=3.2.0" } }, + "node_modules/@embedpdf/fonts-arabic": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@embedpdf/fonts-arabic/-/fonts-arabic-1.0.0.tgz", + "integrity": "sha512-SnGvQb+LwPZQO2WjjvlmXrJZolJUfLYbLZQSaYUw1vrQyMyJKT4LewvJGG+hZ+Yz2fz7OMIQ+4Gc98mGODZtOg==", + "license": "OFL-1.1" + }, + "node_modules/@embedpdf/fonts-hebrew": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@embedpdf/fonts-hebrew/-/fonts-hebrew-1.0.0.tgz", + "integrity": "sha512-5HVAKGL7VqPeTxxADDrSqAFBxfmAXdP8fIqrPwJIKkqdK2643bOer8CqnnpO3/nPoFhkzxhttWMB9BGiqSW62w==", + "license": "OFL-1.1" + }, + "node_modules/@embedpdf/fonts-jp": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@embedpdf/fonts-jp/-/fonts-jp-1.0.0.tgz", + "integrity": "sha512-BY2tv/mcICUUKf+M/bizf3RU65PMqKClJ/e5o9mgMibxyML0OQvEDwYMRPODQkKgJKXCO3ScHmVvcmXp6kt+fA==", + "license": "OFL-1.1" + }, + "node_modules/@embedpdf/fonts-kr": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@embedpdf/fonts-kr/-/fonts-kr-1.0.0.tgz", + "integrity": "sha512-bh88HXSvOBS581kgmihWY7Ijp9hBsvlmXogFG5LSNx9UBAobRcakZiFMGieRBc06hUSkpo7WhjaFM/z/SfQ8dQ==", + "license": "OFL-1.1" + }, + "node_modules/@embedpdf/fonts-latin": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@embedpdf/fonts-latin/-/fonts-latin-1.0.0.tgz", + "integrity": "sha512-LLYysdr8O6sRNzhmW3PbF3AeA8xnqvOi4XLFfIfNlW5uEZ+qsJdcfd78Q78sFJMhlaOAYFMziMMsnOzmx463rA==", + "license": "OFL-1.1" + }, + "node_modules/@embedpdf/fonts-sc": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@embedpdf/fonts-sc/-/fonts-sc-1.0.0.tgz", + "integrity": "sha512-ETXl7XCwaQLSSvMO3EUDwMNqtL64kX2LlFxarTRi/NsIGGOIxUurGfKtrkmtnKHrWy1jAJSt6oxK2uJhvdvQIw==", + "license": "OFL-1.1" + }, + "node_modules/@embedpdf/fonts-tc": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@embedpdf/fonts-tc/-/fonts-tc-1.0.0.tgz", + "integrity": "sha512-rGZJbVD6DYS5BbXdpEMnWkpVF0Knar+bsiyb2o3+YRx7O8eyFubEBQUSUInirQk69HA6fc3GhYCg7TyC/oD76Q==", + "license": "OFL-1.1" + }, "node_modules/@embedpdf/models": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@embedpdf/models/-/models-1.5.0.tgz", - "integrity": "sha512-x/1li3jdag+IzfZkcfRLKLqASLep4v6dgVi3z0JArwaicFra8k1IY2xaVTrwcZyx7pRb/rxvoO9yLHW0Y34NFw==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@embedpdf/models/-/models-2.3.0.tgz", + "integrity": "sha512-YAH3YdXl/UOhVcvMPd6mtU+tJ3veh24Q5swRDfuWUsJ3L2CcAG2P+4pjj4EAwvWUQcmN/HlVOjVQL0PkbkytKw==", "license": "MIT" }, "node_modules/@embedpdf/pdfium": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@embedpdf/pdfium/-/pdfium-1.5.0.tgz", - "integrity": "sha512-PI32t2U4ThZC907n2Iwr8E5WqmC574G83u3V9ysNFl29N9kasrY9RiLSzU4W/yQvXPjIbpQHBsbMKXLjCFBI9w==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@embedpdf/pdfium/-/pdfium-2.3.0.tgz", + "integrity": "sha512-AIWHDDG24we1r8sWVO9Uae6V2ISXji2gIkZS3+CjtYowaBCpMTSu4QEQRnjQam2EWrEMVIJOXwBfx11TZKrxWA==", "license": "MIT" }, "node_modules/@embedpdf/plugin-annotation": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@embedpdf/plugin-annotation/-/plugin-annotation-1.5.0.tgz", - "integrity": "sha512-mxEPI6xYwOGaf9fYfoywuj6nwA10eHFPBuN066MzwphDk6DOHJGZ3Vq8zNQBXh20c/Lb25PL718D7MZWxZLUHg==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@embedpdf/plugin-annotation/-/plugin-annotation-2.3.0.tgz", + "integrity": "sha512-TIN/OiDTg5tCNsebp1SWnS6aa7nnDvRrrZe3jx7Sg5IMEiZc6P3z+0aOjJtvoz0cp3Xi7Bb0PQsTLwo+bdfpVg==", "license": "MIT", "dependencies": { - "@embedpdf/models": "1.5.0", - "@embedpdf/utils": "1.5.0" + "@embedpdf/models": "2.3.0", + "@embedpdf/utils": "2.3.0" }, "peerDependencies": { - "@embedpdf/core": "1.5.0", - "@embedpdf/plugin-history": "1.5.0", - "@embedpdf/plugin-interaction-manager": "1.5.0", - "@embedpdf/plugin-selection": "1.5.0", + "@embedpdf/core": "2.3.0", + "@embedpdf/plugin-history": "2.3.0", + "@embedpdf/plugin-interaction-manager": "2.3.0", + "@embedpdf/plugin-selection": "2.3.0", "preact": "^10.26.4", "react": ">=16.8.0", "react-dom": ">=16.8.0", + "svelte": ">=5 <6", "vue": ">=3.2.0" } }, "node_modules/@embedpdf/plugin-attachment": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@embedpdf/plugin-attachment/-/plugin-attachment-1.5.0.tgz", - "integrity": "sha512-ByIEUDIR7C9H8CnzqsyTFuVOmD7tVme9iHBR668STAuQuK59T23OZbLKWeVzp+iB1o6w7ARxSRejQ3MesvcMMA==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@embedpdf/plugin-attachment/-/plugin-attachment-2.3.0.tgz", + "integrity": "sha512-ADZh4Fqm/n7FzwVo9YeogEePzbS0Novnn6ZF+RV25NF0hDnHjTVaCNitk6tae3R6qhhMSSx+vAC3DHHNhhwI1A==", "license": "MIT", "dependencies": { - "@embedpdf/models": "1.5.0" + "@embedpdf/models": "2.3.0" }, "peerDependencies": { - "@embedpdf/core": "1.5.0", - "preact": "^10.26.4", - "react": ">=16.8.0", - "react-dom": ">=16.8.0" - } - }, - "node_modules/@embedpdf/plugin-bookmark": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@embedpdf/plugin-bookmark/-/plugin-bookmark-1.5.0.tgz", - "integrity": "sha512-s3C9PtVesy5X8Ds/C9TEElFiqfKGRklG/uNPTROpNoolfpi0h7qX2xqqh/9+FzKH2nHjVcPB7Pp432v16h7eRA==", - "license": "MIT", - "dependencies": { - "@embedpdf/models": "1.5.0" - }, - "peerDependencies": { - "@embedpdf/core": "1.5.0", + "@embedpdf/core": "2.3.0", "preact": "^10.26.4", "react": ">=16.8.0", "react-dom": ">=16.8.0", + "svelte": ">=5 <6", + "vue": ">=3.2.0" + } + }, + "node_modules/@embedpdf/plugin-bookmark": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@embedpdf/plugin-bookmark/-/plugin-bookmark-2.3.0.tgz", + "integrity": "sha512-7XO2NntgRb/Jk1XN/EOf7+yVaOPVVFvBuF0xlCqnz2BGAnMNrTn8QE73FtluJBgNhuK9LwDT2C4W+BTD2gd59Q==", + "license": "MIT", + "dependencies": { + "@embedpdf/models": "2.3.0" + }, + "peerDependencies": { + "@embedpdf/core": "2.3.0", + "preact": "^10.26.4", + "react": ">=16.8.0", + "react-dom": ">=16.8.0", + "svelte": ">=5 <6", "vue": ">=3.2.0" } }, "node_modules/@embedpdf/plugin-capture": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@embedpdf/plugin-capture/-/plugin-capture-1.5.0.tgz", - "integrity": "sha512-h9pZ7x+pXjJYMkmXMwbnTNl5+S2IzSYbJUMMVYG++pSAXzeeNjr2z1XiSzjvNCK/x0ChwEDV6tZHfyCNV74Jjw==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@embedpdf/plugin-capture/-/plugin-capture-2.3.0.tgz", + "integrity": "sha512-Q4Btp8f1lafJx32laxCGaX3H3oPfuuwg1I/pbm7wVmlzr+rsnCqqlQzzVqBI/EnCgn5kHH6vPHRogsP0KykvQg==", "license": "MIT", "dependencies": { - "@embedpdf/models": "1.5.0" + "@embedpdf/models": "2.3.0" }, "peerDependencies": { - "@embedpdf/core": "1.5.0", - "@embedpdf/plugin-interaction-manager": "1.5.0", - "@embedpdf/plugin-render": "1.5.0", + "@embedpdf/core": "2.3.0", + "@embedpdf/plugin-interaction-manager": "2.3.0", + "@embedpdf/plugin-render": "2.3.0", + "preact": "^10.26.4", + "react": ">=16.8.0", + "react-dom": ">=16.8.0", + "svelte": ">=5 <6", + "vue": ">=3.2.0" + } + }, + "node_modules/@embedpdf/plugin-commands": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@embedpdf/plugin-commands/-/plugin-commands-2.3.0.tgz", + "integrity": "sha512-zji1CsEk1nEiPS9bGQw02SewaYVZgTs3i561jLRiW+61IuXy9lCBCjsVrkQsRf0RQKydB1UykDw37ibxue68tg==", + "license": "MIT", + "dependencies": { + "@embedpdf/models": "2.3.0" + }, + "peerDependencies": { + "@embedpdf/core": "2.3.0", + "preact": "^10.26.4", + "react": ">=16.8.0", + "react-dom": ">=16.8.0", + "svelte": ">=5 <6", + "vue": ">=3.2.0" + } + }, + "node_modules/@embedpdf/plugin-document-manager": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@embedpdf/plugin-document-manager/-/plugin-document-manager-2.3.0.tgz", + "integrity": "sha512-hdKaWU1sjlLgXo2iWF4N734lklCfSO5Tj1xqk+0omxOpnVL1Ed5fzFO2N584pMkfFn1xo9Y2JPHSUtCdzF7/EQ==", + "license": "MIT", + "dependencies": { + "@embedpdf/models": "2.3.0" + }, + "peerDependencies": { + "@embedpdf/core": "2.3.0", "preact": "^10.26.4", "react": ">=16.8.0", "react-dom": ">=16.8.0", @@ -888,15 +949,15 @@ } }, "node_modules/@embedpdf/plugin-export": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@embedpdf/plugin-export/-/plugin-export-1.5.0.tgz", - "integrity": "sha512-luk68mNW9l2X31qk4b02phKaqDl9aDXUAgHVz1EWrgwXQ3Oz9WEdu60utYARYDiepDo3Caadll8RwctYSf/anA==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@embedpdf/plugin-export/-/plugin-export-2.3.0.tgz", + "integrity": "sha512-Xa048lKnc1jehWbaWv5qER1RVIHhHqt+JhgzAlqFSURXmzowbUzVEDBZ7fYImXRkpqp+ZeyBhWfZ60DBNE55Cw==", "license": "MIT", "dependencies": { - "@embedpdf/models": "1.5.0" + "@embedpdf/models": "2.3.0" }, "peerDependencies": { - "@embedpdf/core": "1.5.0", + "@embedpdf/core": "2.3.0", "preact": "^10.26.4", "react": ">=16.8.0", "react-dom": ">=16.8.0", @@ -905,15 +966,15 @@ } }, "node_modules/@embedpdf/plugin-fullscreen": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@embedpdf/plugin-fullscreen/-/plugin-fullscreen-1.5.0.tgz", - "integrity": "sha512-n2oIhc33vYgdKNaU4ZMYWt1CnNKxdDsZTUHtNK/K/dOywDFPNnVmnEjerdwfmT1Iyf+HJ9UzXQHFO1oODXBlIg==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@embedpdf/plugin-fullscreen/-/plugin-fullscreen-2.3.0.tgz", + "integrity": "sha512-jFDVwW8qphDZ6HQS0iCfYVKHEoaY90vnqcGXFdU0y+hH85PGywbWPy4RRgoHo1CbKsV4xJIyazydHuuDDZicnQ==", "license": "MIT", "dependencies": { - "@embedpdf/models": "1.5.0" + "@embedpdf/models": "2.3.0" }, "peerDependencies": { - "@embedpdf/core": "1.5.0", + "@embedpdf/core": "2.3.0", "preact": "^10.26.4", "react": ">=16.8.0", "react-dom": ">=16.8.0", @@ -922,33 +983,15 @@ } }, "node_modules/@embedpdf/plugin-history": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@embedpdf/plugin-history/-/plugin-history-1.5.0.tgz", - "integrity": "sha512-p7PTNNaIr4gH3jLwX+eLJe1DeUXgi21kVGN6SRx/pocH8esg4jqoOeD/YiRRZoZnPOiy0jBXVhkPkwSmY7a2hQ==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@embedpdf/plugin-history/-/plugin-history-2.3.0.tgz", + "integrity": "sha512-+fr/kjK2Z9BiC53IMlUZvWjkD6iilcI3XCUKQPXRgS5MDAuwpVlgdAtc+3VAMlG3IddElxVFdvvxRO9R89k5Mg==", "license": "MIT", - "peer": true, "dependencies": { - "@embedpdf/models": "1.5.0" + "@embedpdf/models": "2.3.0" }, "peerDependencies": { - "@embedpdf/core": "1.5.0", - "preact": "^10.26.4", - "react": ">=16.8.0", - "react-dom": ">=16.8.0", - "vue": ">=3.2.0" - } - }, - "node_modules/@embedpdf/plugin-interaction-manager": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@embedpdf/plugin-interaction-manager/-/plugin-interaction-manager-1.5.0.tgz", - "integrity": "sha512-ckHgTfvkW6c5Ta7Mc+Dl9C2foVnvEpqEJ84wyBnqrU0OWbe/jsiPhyKBVeartMGqNI/kVfaQTXupyrKhekAVmg==", - "license": "MIT", - "peer": true, - "dependencies": { - "@embedpdf/models": "1.5.0" - }, - "peerDependencies": { - "@embedpdf/core": "1.5.0", + "@embedpdf/core": "2.3.0", "preact": "^10.26.4", "react": ">=16.8.0", "react-dom": ">=16.8.0", @@ -956,17 +999,33 @@ "vue": ">=3.2.0" } }, - "node_modules/@embedpdf/plugin-loader": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@embedpdf/plugin-loader/-/plugin-loader-1.5.0.tgz", - "integrity": "sha512-P4YpIZfaW69etYIjphyaL4cGl2pB14h3OdTE0tRQ2pZYZHFLTvlt4q9B3PVSdhlSrHK5nob7jfLGon2U7xCslg==", + "node_modules/@embedpdf/plugin-i18n": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@embedpdf/plugin-i18n/-/plugin-i18n-2.3.0.tgz", + "integrity": "sha512-OsrYlbgEh21u65SYP5BMssTudEM6Ysl1Te5z1nttT80w28xa9y1QgcO5g7RcVL4/Fqx5Ok5MznAtMRhScTx6kw==", "license": "MIT", - "peer": true, "dependencies": { - "@embedpdf/models": "1.5.0" + "@embedpdf/models": "2.3.0" }, "peerDependencies": { - "@embedpdf/core": "1.5.0", + "@embedpdf/core": "2.3.0", + "preact": "^10.26.4", + "react": ">=16.8.0", + "react-dom": ">=16.8.0", + "svelte": ">=5 <6", + "vue": ">=3.2.0" + } + }, + "node_modules/@embedpdf/plugin-interaction-manager": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@embedpdf/plugin-interaction-manager/-/plugin-interaction-manager-2.3.0.tgz", + "integrity": "sha512-1/tDLPoQm6skNe/WOd6QD7SA0XRKphbJHi/s9XY4fhGgBvlD5XHFrYxtmrsaheYjqIBFtAWWZ3m5lAXRaO/igA==", + "license": "MIT", + "dependencies": { + "@embedpdf/models": "2.3.0" + }, + "peerDependencies": { + "@embedpdf/core": "2.3.0", "preact": "^10.26.4", "react": ">=16.8.0", "react-dom": ">=16.8.0", @@ -975,17 +1034,17 @@ } }, "node_modules/@embedpdf/plugin-pan": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@embedpdf/plugin-pan/-/plugin-pan-1.5.0.tgz", - "integrity": "sha512-EMQ08dHqLkZmFVuLOO6h3AAinFPQoA1r6OlL9z+p0sswq31JAgd4X7+xjYIpI01z/V3+cTzPHzp7qwob5E4tbA==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@embedpdf/plugin-pan/-/plugin-pan-2.3.0.tgz", + "integrity": "sha512-5yGxLpn28PHKCYx3tjzeVir7D5vHZ0Fk9HJRJr4K+Uqbg8pYFavb9tseXzPE4FcqpejqZo2DZyfo54ErQFXEyQ==", "license": "MIT", "dependencies": { - "@embedpdf/models": "1.5.0" + "@embedpdf/models": "2.3.0" }, "peerDependencies": { - "@embedpdf/core": "1.5.0", - "@embedpdf/plugin-interaction-manager": "1.5.0", - "@embedpdf/plugin-viewport": "1.5.0", + "@embedpdf/core": "2.3.0", + "@embedpdf/plugin-interaction-manager": "2.3.0", + "@embedpdf/plugin-viewport": "2.3.0", "preact": "^10.26.4", "react": ">=16.8.0", "react-dom": ">=16.8.0", @@ -994,15 +1053,15 @@ } }, "node_modules/@embedpdf/plugin-print": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@embedpdf/plugin-print/-/plugin-print-1.5.0.tgz", - "integrity": "sha512-rjorvNxAZfO9X4cFZVU9fHnldMWqMceJGmr3mH+yj7KdHePvNDDP+omyZyZKtxlUZENaeDI2h6k5z0GbhBz6sQ==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@embedpdf/plugin-print/-/plugin-print-2.3.0.tgz", + "integrity": "sha512-LNxvXm3rZkRXXC41IArBDiwPLzSflmBmxxi+L+91xvw8n/FWUeXfWwQn7oQEAGq9Ha/3pEVHTls48QSFZN0mhg==", "license": "MIT", "dependencies": { - "@embedpdf/models": "1.5.0" + "@embedpdf/models": "2.3.0" }, "peerDependencies": { - "@embedpdf/core": "1.5.0", + "@embedpdf/core": "2.3.0", "preact": "^10.26.4", "react": ">=18.0.0", "react-dom": ">=18.0.0", @@ -1011,35 +1070,35 @@ } }, "node_modules/@embedpdf/plugin-redaction": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@embedpdf/plugin-redaction/-/plugin-redaction-1.5.0.tgz", - "integrity": "sha512-txiukr5UKAGvJzl6dVBmmIT1v3r/t4e2qYm1hqU2faGgNCa2dwk79x9mDBlvWwxlJXCDFuFE+7Ps9/nU6qmU2w==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@embedpdf/plugin-redaction/-/plugin-redaction-2.3.0.tgz", + "integrity": "sha512-un6AQL5Pqcm9v1tCV9Mb3NeowsGUtlCT/198k4nd+SWOMWNsbuFqI+rWOGV3auqXRGSzKj0gnt29t8aaeLpLeA==", "license": "MIT", "dependencies": { - "@embedpdf/models": "1.5.0", - "@embedpdf/utils": "1.5.0" + "@embedpdf/models": "2.3.0", + "@embedpdf/utils": "2.3.0" }, "peerDependencies": { - "@embedpdf/core": "1.5.0", - "@embedpdf/plugin-interaction-manager": "1.5.0", - "@embedpdf/plugin-selection": "1.5.0", + "@embedpdf/core": "2.3.0", + "@embedpdf/plugin-interaction-manager": "2.3.0", + "@embedpdf/plugin-selection": "2.3.0", "preact": "^10.26.4", "react": ">=16.8.0", "react-dom": ">=16.8.0", + "svelte": ">=5 <6", "vue": ">=3.2.0" } }, "node_modules/@embedpdf/plugin-render": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@embedpdf/plugin-render/-/plugin-render-1.5.0.tgz", - "integrity": "sha512-ywwSj0ByrlkvrJIHKRzqxARkOZriki8VJUC+T4MV8fGyF4CzvCRJyKlPktahFz+VxhoodqTh7lBCib68dH+GvA==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@embedpdf/plugin-render/-/plugin-render-2.3.0.tgz", + "integrity": "sha512-UyQncK5NTokuEVISUcxPOXpZP4SItn4MjfeEaPsTXJkSRjHL4g3mU3iWy0nXJMCOT10OB+5m7qQ0/KkF4f+b5w==", "license": "MIT", - "peer": true, "dependencies": { - "@embedpdf/models": "1.5.0" + "@embedpdf/models": "2.3.0" }, "peerDependencies": { - "@embedpdf/core": "1.5.0", + "@embedpdf/core": "2.3.0", "preact": "^10.26.4", "react": ">=16.8.0", "react-dom": ">=16.8.0", @@ -1048,15 +1107,15 @@ } }, "node_modules/@embedpdf/plugin-rotate": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@embedpdf/plugin-rotate/-/plugin-rotate-1.5.0.tgz", - "integrity": "sha512-5EmBCsq0VfrE3xWY6ofuVm8S6aK95EbAycRIk1wczcmTdvpsuXZ6P2ZaECUgYMcpZ6uAg4/kGf8X8VVZuCihSQ==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@embedpdf/plugin-rotate/-/plugin-rotate-2.3.0.tgz", + "integrity": "sha512-vibDXHA0L2LlMrmkSuanmdtUpc2JPBuQybiGwf9F4wlleKN3f7uSWxZsHdVAxWdzsaG+/26QTGl75otZLnVuig==", "license": "MIT", "dependencies": { - "@embedpdf/models": "1.5.0" + "@embedpdf/models": "2.3.0" }, "peerDependencies": { - "@embedpdf/core": "1.5.0", + "@embedpdf/core": "2.3.0", "preact": "^10.26.4", "react": ">=16.8.0", "react-dom": ">=16.8.0", @@ -1065,17 +1124,16 @@ } }, "node_modules/@embedpdf/plugin-scroll": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@embedpdf/plugin-scroll/-/plugin-scroll-1.5.0.tgz", - "integrity": "sha512-RNmTZCZ8X1mA8cw9M7TMDuhO9GtkOalGha2bBL3En3D1IlDRS7PzNNMSMV7eqT7OQICSTltlpJ8p8Qi5esvL/Q==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@embedpdf/plugin-scroll/-/plugin-scroll-2.3.0.tgz", + "integrity": "sha512-8pdaSY9QuqdX22Ykw2jKn07Rx6FIsDdj/O0+mlbccY/ISofj9WEFNeQgnOY64OUTDyurJYqpYvq6QqvgbGLs+A==", "license": "MIT", - "peer": true, "dependencies": { - "@embedpdf/models": "1.5.0" + "@embedpdf/models": "2.3.0" }, "peerDependencies": { - "@embedpdf/core": "1.5.0", - "@embedpdf/plugin-viewport": "1.5.0", + "@embedpdf/core": "2.3.0", + "@embedpdf/plugin-viewport": "2.3.0", "preact": "^10.26.4", "react": ">=16.8.0", "react-dom": ">=16.8.0", @@ -1084,16 +1142,15 @@ } }, "node_modules/@embedpdf/plugin-search": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@embedpdf/plugin-search/-/plugin-search-1.5.0.tgz", - "integrity": "sha512-TB5b0H8Iobx/azVUBIlG2ClaKtf0y3/Xi3E/iB8BwvkIE2+g6EGfp8IMXIn8WDXST6bbvJEP31Ab0Ilp6SVkiw==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@embedpdf/plugin-search/-/plugin-search-2.3.0.tgz", + "integrity": "sha512-VNXmNf7fIIRWGVwf2kIUeUeLkUTJlq9AGjUO2TyuYJTWTsmfT4LEqPDDpwC6NDVFhzWE6xwbb3bxvY/9bqBMzw==", "license": "MIT", "dependencies": { - "@embedpdf/models": "1.5.0" + "@embedpdf/models": "2.3.0" }, "peerDependencies": { - "@embedpdf/core": "1.5.0", - "@embedpdf/plugin-loader": "1.5.0", + "@embedpdf/core": "2.3.0", "preact": "^10.26.4", "react": ">=16.8.0", "react-dom": ">=16.8.0", @@ -1102,18 +1159,17 @@ } }, "node_modules/@embedpdf/plugin-selection": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@embedpdf/plugin-selection/-/plugin-selection-1.5.0.tgz", - "integrity": "sha512-zrxLBAZQoPswDuf9q9DrYaQc6B0Ysc2U1hueTjNH/4+ydfl0BFXZkKR63C2e3YmWtXvKjkoIj0GyPzsiBORLUw==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@embedpdf/plugin-selection/-/plugin-selection-2.3.0.tgz", + "integrity": "sha512-+emaY4vff3ynAf5C3PfCOlleQIqiImbBpb6zkG5SVUa9Vn5x0SfYGT4Jumtbzq8XBknC1QIRKVlplC9BcnjcmQ==", "license": "MIT", - "peer": true, "dependencies": { - "@embedpdf/models": "1.5.0" + "@embedpdf/models": "2.3.0", + "@embedpdf/utils": "2.3.0" }, "peerDependencies": { - "@embedpdf/core": "1.5.0", - "@embedpdf/plugin-interaction-manager": "1.5.0", - "@embedpdf/plugin-viewport": "1.5.0", + "@embedpdf/core": "2.3.0", + "@embedpdf/plugin-interaction-manager": "2.3.0", "preact": "^10.26.4", "react": ">=16.8.0", "react-dom": ">=16.8.0", @@ -1122,16 +1178,15 @@ } }, "node_modules/@embedpdf/plugin-spread": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@embedpdf/plugin-spread/-/plugin-spread-1.5.0.tgz", - "integrity": "sha512-3EU5Cp+fPQSiMjvMR/P2kXxXry/RlnxHLs4JeskAaH95QcqWW3VD+DrHkWSiLFkdhI18rNNGNlMc5RvDGvbXGQ==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@embedpdf/plugin-spread/-/plugin-spread-2.3.0.tgz", + "integrity": "sha512-sFqYKwzKGPaCXn6hAyv6GHdVTlL2vg3poxRNd2W5kLQo07YtHlSjXr/XAhaGT/a4GtR9rtbSJ4hWNJjzIcwE0g==", "license": "MIT", "dependencies": { - "@embedpdf/models": "1.5.0" + "@embedpdf/models": "2.3.0" }, "peerDependencies": { - "@embedpdf/core": "1.5.0", - "@embedpdf/plugin-loader": "1.5.0", + "@embedpdf/core": "2.3.0", "preact": "^10.26.4", "react": ">=16.8.0", "react-dom": ">=16.8.0", @@ -1140,16 +1195,16 @@ } }, "node_modules/@embedpdf/plugin-thumbnail": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@embedpdf/plugin-thumbnail/-/plugin-thumbnail-1.5.0.tgz", - "integrity": "sha512-Z2qpyyr5s2M6460KDGu1Vk6rdbQFIoCpnyFAT6e7UaTIKkqJSNpmjqMsBU5PosYCFu/cClpHPvS7tg9/IKAk6g==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@embedpdf/plugin-thumbnail/-/plugin-thumbnail-2.3.0.tgz", + "integrity": "sha512-CAOnipeBtdKSHGBuIm5420GykUw7k2rB7Z9GwouTbbycS7Cw+kiaGpOfHfenoKPTlWMkHYAwFcZiWKV3XG/nRQ==", "license": "MIT", "dependencies": { - "@embedpdf/models": "1.5.0" + "@embedpdf/models": "2.3.0" }, "peerDependencies": { - "@embedpdf/core": "1.5.0", - "@embedpdf/plugin-render": "1.5.0", + "@embedpdf/core": "2.3.0", + "@embedpdf/plugin-render": "2.3.0", "preact": "^10.26.4", "react": ">=16.8.0", "react-dom": ">=16.8.0", @@ -1158,18 +1213,18 @@ } }, "node_modules/@embedpdf/plugin-tiling": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@embedpdf/plugin-tiling/-/plugin-tiling-1.5.0.tgz", - "integrity": "sha512-0Vx9elHNpMM+zv8hEoZXBEm8Q0+4kU52LxOlTYRr1A5FskF836sUct6g1ngwK1bmfbAfpz+62PnYI2EeilDZig==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@embedpdf/plugin-tiling/-/plugin-tiling-2.3.0.tgz", + "integrity": "sha512-6VJ042WksIyZVWyvXq1nf0Ct+U4Pl6+QUDy1ThJefwk/HKDfWU2zEr/+1STJKVWgfUx5QRdipf6Jghd+HnOg3Q==", "license": "MIT", "dependencies": { - "@embedpdf/models": "1.5.0" + "@embedpdf/models": "2.3.0" }, "peerDependencies": { - "@embedpdf/core": "1.5.0", - "@embedpdf/plugin-render": "1.5.0", - "@embedpdf/plugin-scroll": "1.5.0", - "@embedpdf/plugin-viewport": "1.5.0", + "@embedpdf/core": "2.3.0", + "@embedpdf/plugin-render": "2.3.0", + "@embedpdf/plugin-scroll": "2.3.0", + "@embedpdf/plugin-viewport": "2.3.0", "preact": "^10.26.4", "react": ">=16.8.0", "react-dom": ">=16.8.0", @@ -1178,31 +1233,35 @@ } }, "node_modules/@embedpdf/plugin-ui": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@embedpdf/plugin-ui/-/plugin-ui-1.5.0.tgz", - "integrity": "sha512-4zW6sRz1b+extrcDxy2gOz01sG7GkuxBUu/sJVpKnBrKzBNix2smzY8SK25nkJY6zT+iP+cPdUoN/r4Atd8Ppg==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@embedpdf/plugin-ui/-/plugin-ui-2.3.0.tgz", + "integrity": "sha512-TTCMBMzQBvD10OiW2v2ptyoVO0bPNAGfL6uHrgaVqUeWr1LlADKrbC+KaSbpUqoMxpPE4knVPi1F3g9X47G69A==", "license": "MIT", "dependencies": { - "@embedpdf/models": "1.5.0" + "@embedpdf/models": "2.3.0" }, "peerDependencies": { - "@embedpdf/core": "1.5.0", + "@embedpdf/core": "2.3.0", + "@embedpdf/plugin-render": "2.3.0", + "@embedpdf/plugin-scroll": "2.3.0", + "@embedpdf/plugin-viewport": "2.3.0", "preact": "^10.26.4", "react": ">=16.8.0", - "react-dom": ">=16.8.0" + "react-dom": ">=16.8.0", + "svelte": ">=5 <6", + "vue": ">=3.2.0" } }, "node_modules/@embedpdf/plugin-viewport": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@embedpdf/plugin-viewport/-/plugin-viewport-1.5.0.tgz", - "integrity": "sha512-G8GDyYRhfehw72+r4qKkydnA5+AU8qH67g01Y12b0DzI0VIzymh/05Z4dK8DsY3jyWPXJfw2hlg5+KDHaMBHgQ==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@embedpdf/plugin-viewport/-/plugin-viewport-2.3.0.tgz", + "integrity": "sha512-3NQp3hVfRF7DMUPNAVOfZsqQQrugEfY0voRUrQI90eyi16GFntN3CP9Mc5cOp2jnUICMYlirQ/om+KCseMHS2Q==", "license": "MIT", - "peer": true, "dependencies": { - "@embedpdf/models": "1.5.0" + "@embedpdf/models": "2.3.0" }, "peerDependencies": { - "@embedpdf/core": "1.5.0", + "@embedpdf/core": "2.3.0", "preact": "^10.26.4", "react": ">=16.8.0", "react-dom": ">=16.8.0", @@ -1211,19 +1270,17 @@ } }, "node_modules/@embedpdf/plugin-zoom": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@embedpdf/plugin-zoom/-/plugin-zoom-1.5.0.tgz", - "integrity": "sha512-LiDkCd5/IXg2CRORl1Yikan2op+AYXSxhHzCFatyBdwzVj+n4y9I74OwCI62Mar8WDAIMyXZDCQxGPToSm+zDw==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@embedpdf/plugin-zoom/-/plugin-zoom-2.3.0.tgz", + "integrity": "sha512-wnBqK02ku0zCViqQfSD1Vohy+aBUogXrqUTwo1/1QFEphmgnCHnHbEUduh9M0ghcT4s26pBgbJqCrShpGYAdvQ==", "license": "MIT", "dependencies": { - "@embedpdf/models": "1.5.0", - "hammerjs": "^2.0.8" + "@embedpdf/models": "2.3.0" }, "peerDependencies": { - "@embedpdf/core": "1.5.0", - "@embedpdf/plugin-interaction-manager": "1.5.0", - "@embedpdf/plugin-scroll": "1.5.0", - "@embedpdf/plugin-viewport": "1.5.0", + "@embedpdf/core": "2.3.0", + "@embedpdf/plugin-scroll": "2.3.0", + "@embedpdf/plugin-viewport": "2.3.0", "preact": "^10.26.4", "react": ">=16.8.0", "react-dom": ">=16.8.0", @@ -1232,14 +1289,15 @@ } }, "node_modules/@embedpdf/utils": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@embedpdf/utils/-/utils-1.5.0.tgz", - "integrity": "sha512-L6jsAPQPGM8ne+MMFAd5gqXb1RNEgNyh16VvVUVKcVnJlBhwil59nVeEQ0cwPhjF5qVeY6MQDIOjBzJqkgXOYg==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@embedpdf/utils/-/utils-2.3.0.tgz", + "integrity": "sha512-9DV+tu+GsnijchNSG/NzslnxTGIUH6j2MxBR8QOoZLsWETEVaMLkHtbvzXPyMOx/5RlvBn8wR0jNKTNptOCnXQ==", "license": "MIT", "peerDependencies": { "preact": "^10.26.4", "react": ">=16.8.0", "react-dom": ">=16.8.0", + "svelte": ">=5 <6", "vue": ">=3.2.0" } }, @@ -1702,134 +1760,105 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.21.1", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", - "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.2.tgz", + "integrity": "sha512-YF+fE6LV4v5MGWRGj7G404/OZzGNepVF8fxk7jqmqo3lrza7a0uUcDnROGRBG1WFC1omYUS/Wp1f42i0M+3Q3A==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/object-schema": "^2.1.7", + "@eslint/object-schema": "^3.0.2", "debug": "^4.3.1", - "minimatch": "^3.1.2" + "minimatch": "^10.2.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/config-helpers": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", - "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.2.tgz", + "integrity": "sha512-a5MxrdDXEvqnIq+LisyCX6tQMPF/dSJpCfBgBauY+pNZ28yCtSsTvyTYrMhaI+LK26bVyCJfJkT0u8KIj2i1dQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.17.0" + "@eslint/core": "^1.1.0" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/core": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", - "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.1.0.tgz", + "integrity": "sha512-/nr9K9wkr3P1EzFTdFdMoLuo1PmIxjmwvPozwoSodjNBdefGujXQUF93u1DDZpEaTuDvMsIQddsd35BwtrW9Xw==", "dev": true, "license": "Apache-2.0", "dependencies": { "@types/json-schema": "^7.0.15" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/eslintrc": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", - "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^6.12.4", - "debug": "^4.3.2", - "espree": "^10.0.1", - "globals": "^14.0.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.1", - "minimatch": "^3.1.2", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint/eslintrc/node_modules/globals": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/js": { - "version": "9.39.2", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", - "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", "dev": true, "license": "MIT", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } } }, "node_modules/@eslint/object-schema": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", - "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.2.tgz", + "integrity": "sha512-HOy56KJt48Bx8KmJ+XGQNSUMT/6dZee/M54XyUyuvTvPXJmsERRvBchsUVx1UMe1WwIH49XLAczNC7V2INsuUw==", "dev": true, "license": "Apache-2.0", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/plugin-kit": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", - "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.6.0.tgz", + "integrity": "sha512-bIZEUzOI1jkhviX2cp5vNyXQc6olzb2ohewQubuYlMXZ2Q/XjBO0x0XhGPvc9fjSIiUN0vw+0hq53BJ4eQSJKQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.17.0", + "@eslint/core": "^1.1.0", "levn": "^0.4.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@exodus/bytes": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.8.0.tgz", - "integrity": "sha512-8JPn18Bcp8Uo1T82gR8lh2guEOa5KKU/IEKvvdp0sgmi7coPBWf1Doi1EXsGZb2ehc8ym/StJCjffYV+ne7sXQ==", + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.14.1.tgz", + "integrity": "sha512-OhkBFWI6GcRMUroChZiopRiSp2iAMvEBK47NhJooDqz1RERO4QuZIZnjP63TXX8GAiLABkYmX+fuQsdJ1dd2QQ==", "dev": true, "license": "MIT", "engines": { "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@exodus/crypto": "^1.0.0-rc.4" + "@noble/hashes": "^1.8.0 || ^2.0.0" }, "peerDependenciesMeta": { - "@exodus/crypto": { + "@noble/hashes": { "optional": true } } @@ -1993,16 +2022,6 @@ "node": ">=12" } }, - "node_modules/@istanbuljs/schema": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", - "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -2065,9 +2084,9 @@ "license": "BSD-2-Clause" }, "node_modules/@kenjiuno/msgreader": { - "version": "1.27.1-alpha.1", - "resolved": "https://registry.npmjs.org/@kenjiuno/msgreader/-/msgreader-1.27.1-alpha.1.tgz", - "integrity": "sha512-r/Fc6cW+68YpYfA8K0uRI31AV484QzcFzJWZkVz5HHBUf1TrzznvSZ9rRwCRqdO2uTLoMtMf7FovZ+MNfa379g==", + "version": "1.28.0", + "resolved": "https://registry.npmjs.org/@kenjiuno/msgreader/-/msgreader-1.28.0.tgz", + "integrity": "sha512-+iv2rWCGRHmX/3sBwXZzkThEuuywGJjnYsvxj6Kp1L/FDMICQcFrtqN+6MFrnh2d+umtfGtX904wxaYEDZ52MQ==", "license": "Apache-2.0", "dependencies": { "@kenjiuno/decompressrtf": "^0.1.3", @@ -2077,10 +2096,25 @@ "node": ">= 10" } }, + "node_modules/@lit-labs/ssr-dom-shim": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.5.1.tgz", + "integrity": "sha512-Aou5UdlSpr5whQe8AA/bZG0jMj96CoJIWbGfZ91qieWu5AWUMKw8VR/pAkQkJYvBNhmCcWnZlyyk5oze8JIqYA==", + "license": "BSD-3-Clause" + }, + "node_modules/@lit/reactive-element": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@lit/reactive-element/-/reactive-element-2.1.2.tgz", + "integrity": "sha512-pbCDiVMnne1lYUIaYNN5wrwQXDtHaYtg7YEFPeW+hws6U47WeFvISGUWekPGKWOP1ygrs0ef0o1VJMk1exos5A==", + "license": "BSD-3-Clause", + "dependencies": { + "@lit-labs/ssr-dom-shim": "^1.5.0" + } + }, "node_modules/@matbee/libreoffice-converter": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/@matbee/libreoffice-converter/-/libreoffice-converter-2.3.1.tgz", - "integrity": "sha512-Nyom9JRxzO6N5rEAIqvxF03k4H2CbQqB+5CtSNBtgSXXJCj3oZYZ7DXgapFyWWbI08nZ3gMdo88uxNrbxMOkyA==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@matbee/libreoffice-converter/-/libreoffice-converter-2.5.0.tgz", + "integrity": "sha512-uOJhx6GL3xbiQp5KBAUU7PcdXvj2/aTBXEtnFQQ5QI5jh8vReodFoBTToxRDaqhLxz4YjKTY22tc15IAa7qHJA==", "license": "MPL-2.0", "dependencies": { "zod": "^4.1.13" @@ -2098,12 +2132,12 @@ } }, "node_modules/@mermaid-js/parser": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-0.6.3.tgz", - "integrity": "sha512-lnjOhe7zyHjc+If7yT4zoedx2vo4sHaTmtkl1+or8BRTnCtDmcTpAjpzDSfCZrshM5bCoz0GyidzadJAH1xobA==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.0.0.tgz", + "integrity": "sha512-vvK0Hi/VWndxoh03Mmz6wa1KDriSPjS2XMZL/1l19HFwygiObEEoEwSDxOqyLzzAI6J2PU3261JjTMTO7x+BPw==", "license": "MIT", "dependencies": { - "langium": "3.3.1" + "langium": "^4.0.0" } }, "node_modules/@napi-rs/canvas": { @@ -2412,6 +2446,18 @@ "dev": true, "license": "MIT" }, + "node_modules/@retejs/lit-plugin": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@retejs/lit-plugin/-/lit-plugin-2.0.7.tgz", + "integrity": "sha512-jnrZ10lwmoxCi9eqViblAi7D8VxMrsGiS/tkn55YOR19xoGxF2Z9rzmdg0RfR8qsNHubHsQDjDFmGbL8tbDmbA==", + "license": "MIT", + "peerDependencies": { + "lit": "^3.0.0", + "rete": "^2.0.0", + "rete-area-plugin": "^2.0.0", + "rete-render-utils": "^2.0.0" + } + }, "node_modules/@rollup/plugin-inject": { "version": "5.0.5", "resolved": "https://registry.npmjs.org/@rollup/plugin-inject/-/plugin-inject-5.0.5.tgz", @@ -2486,9 +2532,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.55.1.tgz", - "integrity": "sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", "cpu": [ "arm" ], @@ -2499,9 +2545,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.55.1.tgz", - "integrity": "sha512-eFZCb1YUqhTysgW3sj/55du5cG57S7UTNtdMjCW7LwVcj3dTTcowCsC8p7uBdzKsZYa8J7IDE8lhMI+HX1vQvg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", "cpu": [ "arm64" ], @@ -2512,9 +2558,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.55.1.tgz", - "integrity": "sha512-p3grE2PHcQm2e8PSGZdzIhCKbMCw/xi9XvMPErPhwO17vxtvCN5FEA2mSLgmKlCjHGMQTP6phuQTYWUnKewwGg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", "cpu": [ "arm64" ], @@ -2525,9 +2571,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.55.1.tgz", - "integrity": "sha512-rDUjG25C9qoTm+e02Esi+aqTKSBYwVTaoS1wxcN47/Luqef57Vgp96xNANwt5npq9GDxsH7kXxNkJVEsWEOEaQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", "cpu": [ "x64" ], @@ -2538,9 +2584,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.55.1.tgz", - "integrity": "sha512-+JiU7Jbp5cdxekIgdte0jfcu5oqw4GCKr6i3PJTlXTCU5H5Fvtkpbs4XJHRmWNXF+hKmn4v7ogI5OQPaupJgOg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", "cpu": [ "arm64" ], @@ -2551,9 +2597,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.55.1.tgz", - "integrity": "sha512-V5xC1tOVWtLLmr3YUk2f6EJK4qksksOYiz/TCsFHu/R+woubcLWdC9nZQmwjOAbmExBIVKsm1/wKmEy4z4u4Bw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", "cpu": [ "x64" ], @@ -2564,9 +2610,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.55.1.tgz", - "integrity": "sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", "cpu": [ "arm" ], @@ -2577,9 +2623,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.55.1.tgz", - "integrity": "sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", "cpu": [ "arm" ], @@ -2590,9 +2636,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.55.1.tgz", - "integrity": "sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", "cpu": [ "arm64" ], @@ -2603,9 +2649,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.55.1.tgz", - "integrity": "sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", "cpu": [ "arm64" ], @@ -2616,9 +2662,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.55.1.tgz", - "integrity": "sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", "cpu": [ "loong64" ], @@ -2629,9 +2675,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.55.1.tgz", - "integrity": "sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", "cpu": [ "loong64" ], @@ -2642,9 +2688,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.55.1.tgz", - "integrity": "sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", "cpu": [ "ppc64" ], @@ -2655,9 +2701,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.55.1.tgz", - "integrity": "sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", "cpu": [ "ppc64" ], @@ -2668,9 +2714,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.55.1.tgz", - "integrity": "sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", "cpu": [ "riscv64" ], @@ -2681,9 +2727,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.55.1.tgz", - "integrity": "sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", "cpu": [ "riscv64" ], @@ -2694,9 +2740,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.55.1.tgz", - "integrity": "sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", "cpu": [ "s390x" ], @@ -2707,9 +2753,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.55.1.tgz", - "integrity": "sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", "cpu": [ "x64" ], @@ -2720,9 +2766,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.55.1.tgz", - "integrity": "sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", "cpu": [ "x64" ], @@ -2733,9 +2779,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.55.1.tgz", - "integrity": "sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", "cpu": [ "x64" ], @@ -2746,9 +2792,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.55.1.tgz", - "integrity": "sha512-xzm44KgEP11te3S2HCSyYf5zIzWmx3n8HDCc7EE59+lTcswEWNpvMLfd9uJvVX8LCg9QWG67Xt75AuHn4vgsXw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", "cpu": [ "arm64" ], @@ -2759,9 +2805,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.55.1.tgz", - "integrity": "sha512-yR6Bl3tMC/gBok5cz/Qi0xYnVbIxGx5Fcf/ca0eB6/6JwOY+SRUcJfI0OpeTpPls7f194as62thCt/2BjxYN8g==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", "cpu": [ "arm64" ], @@ -2772,9 +2818,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.55.1.tgz", - "integrity": "sha512-3fZBidchE0eY0oFZBnekYCfg+5wAB0mbpCBuofh5mZuzIU/4jIVkbESmd2dOsFNS78b53CYv3OAtwqkZZmU5nA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", "cpu": [ "ia32" ], @@ -2785,9 +2831,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.55.1.tgz", - "integrity": "sha512-xGGY5pXj69IxKb4yv/POoocPy/qmEGhimy/FoTpTSVju3FYXUQQMFCaZZXJVidsmGxRioZAwpThl/4zX41gRKg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", "cpu": [ "x64" ], @@ -2798,9 +2844,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.55.1.tgz", - "integrity": "sha512-SPEpaL6DX4rmcXtnhdrQYgzQ5W2uW3SCJch88lB2zImhJRhIIK44fkUrgIV/Q8yUNfw5oyZ5vkeQsZLhCb06lw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", "cpu": [ "x64" ], @@ -2897,11 +2943,19 @@ "dev": true, "license": "MIT" }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@sveltejs/acorn-typescript": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.8.tgz", "integrity": "sha512-esgN+54+q0NjB0Y/4BomT9samII7jGwNy/2a3wNZbT2A2RpmXsXwUt24LvLhx6jUq2gVk4cWEvcRO6MFQbOfNA==", "license": "MIT", + "peer": true, "peerDependencies": { "acorn": "^8.9.0" } @@ -2922,47 +2976,47 @@ "license": "0BSD" }, "node_modules/@tailwindcss/node": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz", - "integrity": "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.1.tgz", + "integrity": "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==", "license": "MIT", "dependencies": { - "@jridgewell/remapping": "^2.3.4", - "enhanced-resolve": "^5.18.3", + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", - "lightningcss": "1.30.2", + "lightningcss": "1.31.1", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", - "tailwindcss": "4.1.18" + "tailwindcss": "4.2.1" } }, "node_modules/@tailwindcss/oxide": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.18.tgz", - "integrity": "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.1.tgz", + "integrity": "sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw==", "license": "MIT", "engines": { - "node": ">= 10" + "node": ">= 20" }, "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.1.18", - "@tailwindcss/oxide-darwin-arm64": "4.1.18", - "@tailwindcss/oxide-darwin-x64": "4.1.18", - "@tailwindcss/oxide-freebsd-x64": "4.1.18", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", - "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", - "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", - "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", - "@tailwindcss/oxide-linux-x64-musl": "4.1.18", - "@tailwindcss/oxide-wasm32-wasi": "4.1.18", - "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", - "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" + "@tailwindcss/oxide-android-arm64": "4.2.1", + "@tailwindcss/oxide-darwin-arm64": "4.2.1", + "@tailwindcss/oxide-darwin-x64": "4.2.1", + "@tailwindcss/oxide-freebsd-x64": "4.2.1", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.1", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.1", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.1", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.1", + "@tailwindcss/oxide-linux-x64-musl": "4.2.1", + "@tailwindcss/oxide-wasm32-wasi": "4.2.1", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.1", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.1" } }, "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.18.tgz", - "integrity": "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.1.tgz", + "integrity": "sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg==", "cpu": [ "arm64" ], @@ -2972,13 +3026,13 @@ "android" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.18.tgz", - "integrity": "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.1.tgz", + "integrity": "sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw==", "cpu": [ "arm64" ], @@ -2988,13 +3042,13 @@ "darwin" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.18.tgz", - "integrity": "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.1.tgz", + "integrity": "sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw==", "cpu": [ "x64" ], @@ -3004,13 +3058,13 @@ "darwin" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.18.tgz", - "integrity": "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.1.tgz", + "integrity": "sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA==", "cpu": [ "x64" ], @@ -3020,13 +3074,13 @@ "freebsd" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.18.tgz", - "integrity": "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.1.tgz", + "integrity": "sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw==", "cpu": [ "arm" ], @@ -3036,13 +3090,13 @@ "linux" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.18.tgz", - "integrity": "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.1.tgz", + "integrity": "sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ==", "cpu": [ "arm64" ], @@ -3052,13 +3106,13 @@ "linux" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.18.tgz", - "integrity": "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.1.tgz", + "integrity": "sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==", "cpu": [ "arm64" ], @@ -3068,13 +3122,13 @@ "linux" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.18.tgz", - "integrity": "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.1.tgz", + "integrity": "sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==", "cpu": [ "x64" ], @@ -3084,13 +3138,13 @@ "linux" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.18.tgz", - "integrity": "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.1.tgz", + "integrity": "sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==", "cpu": [ "x64" ], @@ -3100,13 +3154,13 @@ "linux" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.18.tgz", - "integrity": "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.1.tgz", + "integrity": "sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==", "bundleDependencies": [ "@napi-rs/wasm-runtime", "@emnapi/core", @@ -3121,19 +3175,19 @@ "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1", + "@emnapi/core": "^1.8.1", + "@emnapi/runtime": "^1.8.1", "@emnapi/wasi-threads": "^1.1.0", - "@napi-rs/wasm-runtime": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.1", "@tybys/wasm-util": "^0.10.1", - "tslib": "^2.4.0" + "tslib": "^2.8.1" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { - "version": "1.7.1", + "version": "1.8.1", "inBundle": true, "license": "MIT", "optional": true, @@ -3143,7 +3197,7 @@ } }, "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { - "version": "1.7.1", + "version": "1.8.1", "inBundle": true, "license": "MIT", "optional": true, @@ -3161,7 +3215,7 @@ } }, "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.0", + "version": "1.1.1", "inBundle": true, "license": "MIT", "optional": true, @@ -3169,6 +3223,10 @@ "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" } }, "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { @@ -3187,9 +3245,9 @@ "optional": true }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz", - "integrity": "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.1.tgz", + "integrity": "sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA==", "cpu": [ "arm64" ], @@ -3199,13 +3257,13 @@ "win32" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.18.tgz", - "integrity": "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.1.tgz", + "integrity": "sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ==", "cpu": [ "x64" ], @@ -3215,18 +3273,18 @@ "win32" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, "node_modules/@tailwindcss/vite": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.18.tgz", - "integrity": "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.1.tgz", + "integrity": "sha512-TBf2sJjYeb28jD2U/OhwdW0bbOsxkWPwQ7SrqGf9sVcoYwZj7rkXljroBO9wKBut9XnmQLXanuDUeqQK0lGg/w==", "license": "MIT", "dependencies": { - "@tailwindcss/node": "4.1.18", - "@tailwindcss/oxide": "4.1.18", - "tailwindcss": "4.1.18" + "@tailwindcss/node": "4.2.1", + "@tailwindcss/oxide": "4.2.1", + "tailwindcss": "4.2.1" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" @@ -3540,10 +3598,11 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/emscripten": { - "version": "1.41.5", - "resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.41.5.tgz", - "integrity": "sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q==", + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, "license": "MIT" }, "node_modules/@types/estree": { @@ -3597,7 +3656,6 @@ "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz", "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==", "license": "MIT", - "peer": true, "dependencies": { "@types/linkify-it": "^5", "@types/mdurl": "^2" @@ -3653,9 +3711,9 @@ } }, "node_modules/@types/pdfkit": { - "version": "0.17.4", - "resolved": "https://registry.npmjs.org/@types/pdfkit/-/pdfkit-0.17.4.tgz", - "integrity": "sha512-odAmVuuguRxKh1X4pbMrJMp8ecwNqHRw6lweupvzK+wuyNmi6wzlUlGVZ9EqMvp3Bs2+L9Ty0sRlrvKL+gsQZg==", + "version": "0.17.5", + "resolved": "https://registry.npmjs.org/@types/pdfkit/-/pdfkit-0.17.5.tgz", + "integrity": "sha512-T3ZHnvF91HsEco5ClhBCOuBwobZfPcI2jaiSHybkkKYq4KhVIIurod94JVKvDIG0JXT6o3KiERC0X0//m8dyrg==", "dev": true, "license": "MIT", "dependencies": { @@ -3680,8 +3738,7 @@ "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/@types/unist": { "version": "3.0.3", @@ -3708,20 +3765,20 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.51.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.51.0.tgz", - "integrity": "sha512-XtssGWJvypyM2ytBnSnKtHYOGT+4ZwTnBVl36TA4nRO2f4PRNGz5/1OszHzcZCvcBMh+qb7I06uoCmLTRdR9og==", + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.1.tgz", + "integrity": "sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.51.0", - "@typescript-eslint/type-utils": "8.51.0", - "@typescript-eslint/utils": "8.51.0", - "@typescript-eslint/visitor-keys": "8.51.0", - "ignore": "^7.0.0", + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.56.1", + "@typescript-eslint/type-utils": "8.56.1", + "@typescript-eslint/utils": "8.56.1", + "@typescript-eslint/visitor-keys": "8.56.1", + "ignore": "^7.0.5", "natural-compare": "^1.4.0", - "ts-api-utils": "^2.2.0" + "ts-api-utils": "^2.4.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3731,8 +3788,8 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.51.0", - "eslint": "^8.57.0 || ^9.0.0", + "@typescript-eslint/parser": "^8.56.1", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, @@ -3747,18 +3804,17 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.51.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.51.0.tgz", - "integrity": "sha512-3xP4XzzDNQOIqBMWogftkwxhg5oMKApqY0BAflmLZiFYHqyhSOxv/cd/zPQLTcCXr4AkaKb25joocY0BD1WC6A==", + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.56.1.tgz", + "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@typescript-eslint/scope-manager": "8.51.0", - "@typescript-eslint/types": "8.51.0", - "@typescript-eslint/typescript-estree": "8.51.0", - "@typescript-eslint/visitor-keys": "8.51.0", - "debug": "^4.3.4" + "@typescript-eslint/scope-manager": "8.56.1", + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/typescript-estree": "8.56.1", + "@typescript-eslint/visitor-keys": "8.56.1", + "debug": "^4.4.3" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3768,20 +3824,20 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.51.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.51.0.tgz", - "integrity": "sha512-Luv/GafO07Z7HpiI7qeEW5NW8HUtZI/fo/kE0YbtQEFpJRUuR0ajcWfCE5bnMvL7QQFrmT/odMe8QZww8X2nfQ==", + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.56.1.tgz", + "integrity": "sha512-TAdqQTzHNNvlVFfR+hu2PDJrURiwKsUvxFn1M0h95BB8ah5jejas08jUWG4dBA68jDMI988IvtfdAI53JzEHOQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.51.0", - "@typescript-eslint/types": "^8.51.0", - "debug": "^4.3.4" + "@typescript-eslint/tsconfig-utils": "^8.56.1", + "@typescript-eslint/types": "^8.56.1", + "debug": "^4.4.3" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3795,14 +3851,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.51.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.51.0.tgz", - "integrity": "sha512-JhhJDVwsSx4hiOEQPeajGhCWgBMBwVkxC/Pet53EpBVs7zHHtayKefw1jtPaNRXpI9RA2uocdmpdfE7T+NrizA==", + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.56.1.tgz", + "integrity": "sha512-YAi4VDKcIZp0O4tz/haYKhmIDZFEUPOreKbfdAN3SzUDMcPhJ8QI99xQXqX+HoUVq8cs85eRKnD+rne2UAnj2w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.51.0", - "@typescript-eslint/visitor-keys": "8.51.0" + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/visitor-keys": "8.56.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3813,9 +3869,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.51.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.51.0.tgz", - "integrity": "sha512-Qi5bSy/vuHeWyir2C8u/uqGMIlIDu8fuiYWv48ZGlZ/k+PRPHtaAu7erpc7p5bzw2WNNSniuxoMSO4Ar6V9OXw==", + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.56.1.tgz", + "integrity": "sha512-qOtCYzKEeyr3aR9f28mPJqBty7+DBqsdd63eO0yyDwc6vgThj2UjWfJIcsFeSucYydqcuudMOprZ+x1SpF3ZuQ==", "dev": true, "license": "MIT", "engines": { @@ -3830,17 +3886,17 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.51.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.51.0.tgz", - "integrity": "sha512-0XVtYzxnobc9K0VU7wRWg1yiUrw4oQzexCG2V2IDxxCxhqBMSMbjB+6o91A+Uc0GWtgjCa3Y8bi7hwI0Tu4n5Q==", + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.56.1.tgz", + "integrity": "sha512-yB/7dxi7MgTtGhZdaHCemf7PuwrHMenHjmzgUW1aJpO+bBU43OycnM3Wn+DdvDO/8zzA9HlhaJ0AUGuvri4oGg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.51.0", - "@typescript-eslint/typescript-estree": "8.51.0", - "@typescript-eslint/utils": "8.51.0", - "debug": "^4.3.4", - "ts-api-utils": "^2.2.0" + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/typescript-estree": "8.56.1", + "@typescript-eslint/utils": "8.56.1", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3850,14 +3906,14 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/types": { - "version": "8.51.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.51.0.tgz", - "integrity": "sha512-TizAvWYFM6sSscmEakjY3sPqGwxZRSywSsPEiuZF6d5GmGD9Gvlsv0f6N8FvAAA0CD06l3rIcWNbsN1e5F/9Ag==", + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.56.1.tgz", + "integrity": "sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw==", "dev": true, "license": "MIT", "engines": { @@ -3869,21 +3925,21 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.51.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.51.0.tgz", - "integrity": "sha512-1qNjGqFRmlq0VW5iVlcyHBbCjPB7y6SxpBkrbhNWMy/65ZoncXCEPJxkRZL8McrseNH6lFhaxCIaX+vBuFnRng==", + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.56.1.tgz", + "integrity": "sha512-qzUL1qgalIvKWAf9C1HpvBjif+Vm6rcT5wZd4VoMb9+Km3iS3Cv9DY6dMRMDtPnwRAFyAi7YXJpTIEXLvdfPxg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.51.0", - "@typescript-eslint/tsconfig-utils": "8.51.0", - "@typescript-eslint/types": "8.51.0", - "@typescript-eslint/visitor-keys": "8.51.0", - "debug": "^4.3.4", - "minimatch": "^9.0.4", - "semver": "^7.6.0", + "@typescript-eslint/project-service": "8.56.1", + "@typescript-eslint/tsconfig-utils": "8.56.1", + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/visitor-keys": "8.56.1", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", "tinyglobby": "^0.2.15", - "ts-api-utils": "^2.2.0" + "ts-api-utils": "^2.4.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3896,43 +3952,17 @@ "typescript": ">=4.8.4 <6.0.0" } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/@typescript-eslint/utils": { - "version": "8.51.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.51.0.tgz", - "integrity": "sha512-11rZYxSe0zabiKaCP2QAwRf/dnmgFgvTmeDTtZvUvXG3UuAdg/GU02NExmmIXzz3vLGgMdtrIosI84jITQOxUA==", + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.56.1.tgz", + "integrity": "sha512-HPAVNIME3tABJ61siYlHzSWCGtOoeP2RTIaHXFMPqjrQKCGB9OgUVdiNgH7TJS2JNIQ5qQ4RsAUDuGaGme/KOA==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.51.0", - "@typescript-eslint/types": "8.51.0", - "@typescript-eslint/typescript-estree": "8.51.0" + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.56.1", + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/typescript-estree": "8.56.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3942,19 +3972,19 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.51.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.51.0.tgz", - "integrity": "sha512-mM/JRQOzhVN1ykejrvwnBRV3+7yTKK8tVANVN3o1O0t0v7o+jqdVu9crPy5Y9dov15TJk/FTIgoUGHrTOVL3Zg==", + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.56.1.tgz", + "integrity": "sha512-KiROIzYdEV85YygXw6BI/Dx4fnBlFQu6Mq4QE4MOH9fFnhohw6wX/OAvDY2/C+ut0I3RSPKenvZJIVYqJNkhEw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.51.0", - "eslint-visitor-keys": "^4.2.1" + "@typescript-eslint/types": "8.56.1", + "eslint-visitor-keys": "^5.0.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3971,33 +4001,43 @@ "dev": true, "license": "ISC" }, + "node_modules/@vitejs/plugin-basic-ssl": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-basic-ssl/-/plugin-basic-ssl-2.1.4.tgz", + "integrity": "sha512-HXciTXN/sDBYWgeAD4V4s0DN0g72x5mlxQhHxtYu3Tt8BLa6MzcJZUyDVFCdtjNs3bfENVHVzOsmooTVuNgAAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "peerDependencies": { + "vite": "^6.0.0 || ^7.0.0" + } + }, "node_modules/@vitest/coverage-v8": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.4.tgz", - "integrity": "sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==", + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.18.tgz", + "integrity": "sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg==", "dev": true, "license": "MIT", "dependencies": { - "@ampproject/remapping": "^2.3.0", "@bcoe/v8-coverage": "^1.0.2", - "ast-v8-to-istanbul": "^0.3.3", - "debug": "^4.4.1", + "@vitest/utils": "4.0.18", + "ast-v8-to-istanbul": "^0.3.10", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", - "istanbul-lib-source-maps": "^5.0.6", - "istanbul-reports": "^3.1.7", - "magic-string": "^0.30.17", - "magicast": "^0.3.5", - "std-env": "^3.9.0", - "test-exclude": "^7.0.1", - "tinyrainbow": "^2.0.0" + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.1", + "obug": "^2.1.1", + "std-env": "^3.10.0", + "tinyrainbow": "^3.0.3" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "3.2.4", - "vitest": "3.2.4" + "@vitest/browser": "4.0.18", + "vitest": "4.0.18" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -4006,39 +4046,40 @@ } }, "node_modules/@vitest/expect": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", - "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", + "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==", "dev": true, "license": "MIT", "dependencies": { + "@standard-schema/spec": "^1.0.0", "@types/chai": "^5.2.2", - "@vitest/spy": "3.2.4", - "@vitest/utils": "3.2.4", - "chai": "^5.2.0", - "tinyrainbow": "^2.0.0" + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "chai": "^6.2.1", + "tinyrainbow": "^3.0.3" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/mocker": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", - "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz", + "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "3.2.4", + "@vitest/spy": "4.0.18", "estree-walker": "^3.0.3", - "magic-string": "^0.30.17" + "magic-string": "^0.30.21" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { "msw": "^2.4.9", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + "vite": "^6.0.0 || ^7.0.0-0" }, "peerDependenciesMeta": { "msw": { @@ -4050,42 +4091,41 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", - "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz", + "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==", "dev": true, "license": "MIT", "dependencies": { - "tinyrainbow": "^2.0.0" + "tinyrainbow": "^3.0.3" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/runner": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", - "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz", + "integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "3.2.4", - "pathe": "^2.0.3", - "strip-literal": "^3.0.0" + "@vitest/utils": "4.0.18", + "pathe": "^2.0.3" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/snapshot": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", - "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz", + "integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.2.4", - "magic-string": "^0.30.17", + "@vitest/pretty-format": "4.0.18", + "magic-string": "^0.30.21", "pathe": "^2.0.3" }, "funding": { @@ -4093,73 +4133,68 @@ } }, "node_modules/@vitest/spy": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", - "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz", + "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==", "dev": true, "license": "MIT", - "dependencies": { - "tinyspy": "^4.0.3" - }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/ui": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-3.2.4.tgz", - "integrity": "sha512-hGISOaP18plkzbWEcP/QvtRW1xDXF2+96HbEX6byqQhAUbiS5oH6/9JwW+QsQCIYON2bI6QZBF+2PvOmrRZ9wA==", + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.0.18.tgz", + "integrity": "sha512-CGJ25bc8fRi8Lod/3GHSvXRKi7nBo3kxh0ApW4yCjmrWmRmlT53B5E08XRSZRliygG0aVNxLrBEqPYdz/KcCtQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@vitest/utils": "3.2.4", + "@vitest/utils": "4.0.18", "fflate": "^0.8.2", "flatted": "^3.3.3", "pathe": "^2.0.3", - "sirv": "^3.0.1", - "tinyglobby": "^0.2.14", - "tinyrainbow": "^2.0.0" + "sirv": "^3.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "vitest": "3.2.4" + "vitest": "4.0.18" } }, "node_modules/@vitest/utils": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", - "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz", + "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.2.4", - "loupe": "^3.1.4", - "tinyrainbow": "^2.0.0" + "@vitest/pretty-format": "4.0.18", + "tinyrainbow": "^3.0.3" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vue/compiler-core": { - "version": "3.5.26", - "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.26.tgz", - "integrity": "sha512-vXyI5GMfuoBCnv5ucIT7jhHKl55Y477yxP6fc4eUswjP8FG3FFVFd41eNDArR+Uk3QKn2Z85NavjaxLxOC19/w==", + "version": "3.5.29", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.29.tgz", + "integrity": "sha512-cuzPhD8fwRHk8IGfmYaR4eEe4cAyJEL66Ove/WZL7yWNL134nqLddSLwNRIsFlnnW1kK+p8Ck3viFnC0chXCXw==", "license": "MIT", "dependencies": { - "@babel/parser": "^7.28.5", - "@vue/shared": "3.5.26", - "entities": "^7.0.0", + "@babel/parser": "^7.29.0", + "@vue/shared": "3.5.29", + "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "node_modules/@vue/compiler-core/node_modules/entities": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.0.tgz", - "integrity": "sha512-FDWG5cmEYf2Z00IkYRhbFrwIwvdFKH07uV8dvNy0omp/Qb1xcyCWp2UDtcwJF4QZZvk0sLudP6/hAu42TaqVhQ==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", "license": "BSD-2-Clause", "engines": { "node": ">=0.12" @@ -4175,26 +4210,26 @@ "license": "MIT" }, "node_modules/@vue/compiler-dom": { - "version": "3.5.26", - "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.26.tgz", - "integrity": "sha512-y1Tcd3eXs834QjswshSilCBnKGeQjQXB6PqFn/1nxcQw4pmG42G8lwz+FZPAZAby6gZeHSt/8LMPfZ4Rb+Bd/A==", + "version": "3.5.29", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.29.tgz", + "integrity": "sha512-n0G5o7R3uBVmVxjTIYcz7ovr8sy7QObFG8OQJ3xGCDNhbG60biP/P5KnyY8NLd81OuT1WJflG7N4KWYHaeeaIg==", "license": "MIT", "dependencies": { - "@vue/compiler-core": "3.5.26", - "@vue/shared": "3.5.26" + "@vue/compiler-core": "3.5.29", + "@vue/shared": "3.5.29" } }, "node_modules/@vue/compiler-sfc": { - "version": "3.5.26", - "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.26.tgz", - "integrity": "sha512-egp69qDTSEZcf4bGOSsprUr4xI73wfrY5oRs6GSgXFTiHrWj4Y3X5Ydtip9QMqiCMCPVwLglB9GBxXtTadJ3mA==", + "version": "3.5.29", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.29.tgz", + "integrity": "sha512-oJZhN5XJs35Gzr50E82jg2cYdZQ78wEwvRO6Y63TvLVTc+6xICzJHP1UIecdSPPYIbkautNBanDiWYa64QSFIA==", "license": "MIT", "dependencies": { - "@babel/parser": "^7.28.5", - "@vue/compiler-core": "3.5.26", - "@vue/compiler-dom": "3.5.26", - "@vue/compiler-ssr": "3.5.26", - "@vue/shared": "3.5.26", + "@babel/parser": "^7.29.0", + "@vue/compiler-core": "3.5.29", + "@vue/compiler-dom": "3.5.29", + "@vue/compiler-ssr": "3.5.29", + "@vue/shared": "3.5.29", "estree-walker": "^2.0.2", "magic-string": "^0.30.21", "postcss": "^8.5.6", @@ -4208,13 +4243,13 @@ "license": "MIT" }, "node_modules/@vue/compiler-ssr": { - "version": "3.5.26", - "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.26.tgz", - "integrity": "sha512-lZT9/Y0nSIRUPVvapFJEVDbEXruZh2IYHMk2zTtEgJSlP5gVOqeWXH54xDKAaFS4rTnDeDBQUYDtxKyoW9FwDw==", + "version": "3.5.29", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.29.tgz", + "integrity": "sha512-Y/ARJZE6fpjzL5GH/phJmsFwx3g6t2KmHKHx5q+MLl2kencADKIrhH5MLF6HHpRMmlRAYBRSvv347Mepf1zVNw==", "license": "MIT", "dependencies": { - "@vue/compiler-dom": "3.5.26", - "@vue/shared": "3.5.26" + "@vue/compiler-dom": "3.5.29", + "@vue/shared": "3.5.29" } }, "node_modules/@vue/devtools-api": { @@ -4254,53 +4289,53 @@ } }, "node_modules/@vue/reactivity": { - "version": "3.5.26", - "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.26.tgz", - "integrity": "sha512-9EnYB1/DIiUYYnzlnUBgwU32NNvLp/nhxLXeWRhHUEeWNTn1ECxX8aGO7RTXeX6PPcxe3LLuNBFoJbV4QZ+CFQ==", + "version": "3.5.29", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.29.tgz", + "integrity": "sha512-zcrANcrRdcLtmGZETBxWqIkoQei8HaFpZWx/GHKxx79JZsiZ8j1du0VUJtu4eJjgFvU/iKL5lRXFXksVmI+5DA==", "license": "MIT", "dependencies": { - "@vue/shared": "3.5.26" + "@vue/shared": "3.5.29" } }, "node_modules/@vue/runtime-core": { - "version": "3.5.26", - "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.26.tgz", - "integrity": "sha512-xJWM9KH1kd201w5DvMDOwDHYhrdPTrAatn56oB/LRG4plEQeZRQLw0Bpwih9KYoqmzaxF0OKSn6swzYi84e1/Q==", + "version": "3.5.29", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.29.tgz", + "integrity": "sha512-8DpW2QfdwIWOLqtsNcds4s+QgwSaHSJY/SUe04LptianUQ/0xi6KVsu/pYVh+HO3NTVvVJjIPL2t6GdeKbS4Lg==", "license": "MIT", "dependencies": { - "@vue/reactivity": "3.5.26", - "@vue/shared": "3.5.26" + "@vue/reactivity": "3.5.29", + "@vue/shared": "3.5.29" } }, "node_modules/@vue/runtime-dom": { - "version": "3.5.26", - "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.26.tgz", - "integrity": "sha512-XLLd/+4sPC2ZkN/6+V4O4gjJu6kSDbHAChvsyWgm1oGbdSO3efvGYnm25yCjtFm/K7rrSDvSfPDgN1pHgS4VNQ==", + "version": "3.5.29", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.29.tgz", + "integrity": "sha512-AHvvJEtcY9tw/uk+s/YRLSlxxQnqnAkjqvK25ZiM4CllCZWzElRAoQnCM42m9AHRLNJ6oe2kC5DCgD4AUdlvXg==", "license": "MIT", "dependencies": { - "@vue/reactivity": "3.5.26", - "@vue/runtime-core": "3.5.26", - "@vue/shared": "3.5.26", + "@vue/reactivity": "3.5.29", + "@vue/runtime-core": "3.5.29", + "@vue/shared": "3.5.29", "csstype": "^3.2.3" } }, "node_modules/@vue/server-renderer": { - "version": "3.5.26", - "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.26.tgz", - "integrity": "sha512-TYKLXmrwWKSodyVuO1WAubucd+1XlLg4set0YoV+Hu8Lo79mp/YMwWV5mC5FgtsDxX3qo1ONrxFaTP1OQgy1uA==", + "version": "3.5.29", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.29.tgz", + "integrity": "sha512-G/1k6WK5MusLlbxSE2YTcqAAezS+VuwHhOvLx2KnQU7G2zCH6KIb+5Wyt6UjMq7a3qPzNEjJXs1hvAxDclQH+g==", "license": "MIT", "dependencies": { - "@vue/compiler-ssr": "3.5.26", - "@vue/shared": "3.5.26" + "@vue/compiler-ssr": "3.5.29", + "@vue/shared": "3.5.29" }, "peerDependencies": { - "vue": "3.5.26" + "vue": "3.5.29" } }, "node_modules/@vue/shared": { - "version": "3.5.26", - "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.26.tgz", - "integrity": "sha512-7Z6/y3uFI5PRoKeorTOSXKcDj0MSasfNNltcslbFrPpcw6aXRUALq4IfJlaTRspiWIUOEZbrpM+iQGmCOiWe4A==", + "version": "3.5.29", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.29.tgz", + "integrity": "sha512-w7SR0A5zyRByL9XUkCfdLs7t9XOHUyJ67qPGQjOou3p6GvBeBW+AVjUUmlxtZ4PIYaRvE+1LmK44O4uajlZwcg==", "license": "MIT" }, "node_modules/@vueuse/core": { @@ -4422,11 +4457,10 @@ } }, "node_modules/acorn": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4455,9 +4489,9 @@ } }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", "dependencies": { @@ -4477,7 +4511,6 @@ "integrity": "sha512-qqAXW9QvKf2tTyhpDA4qXv1IfBwD2eduSW6tUEBFIfCeE9gn9HQ9I5+MaKoenRuHrzk5sQoNh1/iof8mY7uD6Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@algolia/abtesting": "1.12.2", "@algolia/client-abtesting": "5.46.2", @@ -4692,6 +4725,7 @@ "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", "license": "Apache-2.0", + "peer": true, "engines": { "node": ">= 0.4" } @@ -4819,14 +4853,26 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/brace-expansion/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" } }, "node_modules/braces": { @@ -5053,14 +5099,13 @@ "dev": true, "license": "MIT" }, - "node_modules/cac": { - "version": "6.7.14", - "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", - "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", - "dev": true, + "node_modules/bwip-js": { + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/bwip-js/-/bwip-js-4.8.0.tgz", + "integrity": "sha512-gUDkDHSTv8/DJhomSIbO0fX/Dx0MO/sgllLxJyJfu4WixCQe9nfGJzmHm64ZCbxo+gUYQEsQcRmqcwcwPRwUkg==", "license": "MIT", - "engines": { - "node": ">=8" + "bin": { + "bwip-js": "bin/bwip-js.js" } }, "node_modules/call-bind": { @@ -5113,16 +5158,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/canvg": { "version": "3.0.11", "resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz", @@ -5155,18 +5190,11 @@ } }, "node_modules/chai": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", - "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", "dev": true, "license": "MIT", - "dependencies": { - "assertion-error": "^2.0.1", - "check-error": "^2.1.1", - "deep-eql": "^5.0.1", - "loupe": "^3.1.0", - "pathval": "^2.0.0" - }, "engines": { "node": ">=18" } @@ -5210,28 +5238,18 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/check-error": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", - "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 16" - } - }, "node_modules/chevrotain": { - "version": "11.0.3", - "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.0.3.tgz", - "integrity": "sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw==", + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.1.2.tgz", + "integrity": "sha512-opLQzEVriiH1uUQ4Kctsd49bRoFDXGGSC4GUqj7pGyxM3RehRhvTlZJc1FL/Flew2p5uwxa1tUDWKzI4wNM8pg==", "license": "Apache-2.0", "dependencies": { - "@chevrotain/cst-dts-gen": "11.0.3", - "@chevrotain/gast": "11.0.3", - "@chevrotain/regexp-to-ast": "11.0.3", - "@chevrotain/types": "11.0.3", - "@chevrotain/utils": "11.0.3", - "lodash-es": "4.17.21" + "@chevrotain/cst-dts-gen": "11.1.2", + "@chevrotain/gast": "11.1.2", + "@chevrotain/regexp-to-ast": "11.1.2", + "@chevrotain/types": "11.1.2", + "@chevrotain/utils": "11.1.2", + "lodash-es": "4.17.23" } }, "node_modules/chevrotain-allstar": { @@ -5246,12 +5264,6 @@ "chevrotain": "^11.0.0" } }, - "node_modules/chevrotain/node_modules/lodash-es": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", - "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", - "license": "MIT" - }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -5367,6 +5379,7 @@ "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", "license": "MIT", + "peer": true, "engines": { "node": ">=6" } @@ -5408,9 +5421,9 @@ } }, "node_modules/commander": { - "version": "14.0.2", - "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.2.tgz", - "integrity": "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ==", + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", "dev": true, "license": "MIT", "engines": { @@ -5433,13 +5446,6 @@ "node": ">= 14" } }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" - }, "node_modules/confbox": { "version": "0.1.8", "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", @@ -5667,16 +5673,16 @@ } }, "node_modules/cssstyle": { - "version": "5.3.6", - "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.6.tgz", - "integrity": "sha512-legscpSpgSAeGEe0TNcai97DKt9Vd9AsAdOL7Uoetb52Ar/8eJm3LIa39qpv8wWzLFlNG4vVvppQM+teaMPj3A==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-6.1.0.tgz", + "integrity": "sha512-Ml4fP2UT2K3CUBQnVlbdV/8aFDdlY69E+YnwJM+3VUWl08S3J8c8aRuJqCkD9Py8DHZ7zNNvsfKl8psocHZEFg==", "dev": true, "license": "MIT", "dependencies": { - "@asamuzakjp/css-color": "^4.1.1", - "@csstools/css-syntax-patches-for-csstree": "^1.0.21", + "@asamuzakjp/css-color": "^5.0.0", + "@csstools/css-syntax-patches-for-csstree": "^1.0.28", "css-tree": "^3.1.0", - "lru-cache": "^11.2.4" + "lru-cache": "^11.2.6" }, "engines": { "node": ">=20" @@ -5693,7 +5699,6 @@ "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.1.tgz", "integrity": "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10" } @@ -6103,7 +6108,6 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -6199,17 +6203,17 @@ } }, "node_modules/data-urls": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.0.tgz", - "integrity": "sha512-BnBS08aLUM+DKamupXs3w2tJJoqU+AkaE/+6vQxi/G/DPmIZFJJp9Dkb1kM03AZx8ADehDUZgsNxju3mPXZYIA==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", "dev": true, "license": "MIT", "dependencies": { - "whatwg-mimetype": "^4.0.0", - "whatwg-url": "^15.0.0" + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" }, "engines": { - "node": ">=20" + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, "node_modules/dayjs": { @@ -6243,16 +6247,6 @@ "dev": true, "license": "MIT" }, - "node_modules/deep-eql": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", - "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -6336,10 +6330,11 @@ } }, "node_modules/devalue": { - "version": "5.6.1", - "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.1.tgz", - "integrity": "sha512-jDwizj+IlEZBunHcOuuFVBnIMPAEHvTsJj0BcIp94xYguLRVBcXO853px/MyIJvbVzWdsGvrRweIUWJw8hBP7A==", - "license": "MIT" + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.3.tgz", + "integrity": "sha512-nc7XjUU/2Lb+SvEFVGcWLiKkzfw8+qHI7zn8WYXKkLMgfGSHbgCEaR6bJpev8Cm6Rmrb19Gfd/tZvGqx9is3wg==", + "license": "MIT", + "peer": true }, "node_modules/devlop": { "version": "1.1.0", @@ -6361,6 +6356,15 @@ "integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==", "license": "MIT" }, + "node_modules/diff": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.3.tgz", + "integrity": "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, "node_modules/diffie-hellman": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/diffie-hellman/-/diffie-hellman-5.0.3.tgz", @@ -6401,10 +6405,13 @@ } }, "node_modules/dompurify": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz", - "integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==", + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.2.tgz", + "integrity": "sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ==", "license": "(MPL-2.0 OR Apache-2.0)", + "engines": { + "node": ">=20" + }, "optionalDependencies": { "@types/trusted-types": "^2.0.7" } @@ -6454,39 +6461,42 @@ "license": "MIT" }, "node_modules/embedpdf-snippet": { - "version": "1.0.0", - "resolved": "file:vendor/embedpdf/embedpdf-snippet-1.5.0.tgz", - "integrity": "sha512-33SKBKkJEpGpo6Msuq2T7fBkHCzqLc58xI+Ui1C3DLWDmMynwT81/RoB2IQga3Ul1uU07CJRl1/OAwXhrlYWyA==", + "version": "2.3.0", + "resolved": "file:vendor/embedpdf/embedpdf-snippet-2.3.0.tgz", + "integrity": "sha512-1rhNxdAcbj3OXqkjXVfo6IrhH67bIikHvrN97vBtlkIGCi5w9lsIYs5eXdgwOLKzWJGI1plMfjQV47l4hzteag==", "license": "MIT", "dependencies": { - "@embedpdf/core": "^1.5.0", - "@embedpdf/engines": "^1.5.0", - "@embedpdf/models": "^1.5.0", - "@embedpdf/pdfium": "^1.5.0", - "@embedpdf/plugin-annotation": "^1.5.0", - "@embedpdf/plugin-attachment": "^1.5.0", - "@embedpdf/plugin-bookmark": "^1.5.0", - "@embedpdf/plugin-capture": "^1.5.0", - "@embedpdf/plugin-export": "^1.5.0", - "@embedpdf/plugin-fullscreen": "^1.5.0", - "@embedpdf/plugin-history": "^1.5.0", - "@embedpdf/plugin-interaction-manager": "^1.5.0", - "@embedpdf/plugin-loader": "^1.5.0", - "@embedpdf/plugin-pan": "^1.5.0", - "@embedpdf/plugin-print": "^1.5.0", - "@embedpdf/plugin-redaction": "^1.5.0", - "@embedpdf/plugin-render": "^1.5.0", - "@embedpdf/plugin-rotate": "^1.5.0", - "@embedpdf/plugin-scroll": "^1.5.0", - "@embedpdf/plugin-search": "^1.5.0", - "@embedpdf/plugin-selection": "^1.5.0", - "@embedpdf/plugin-spread": "^1.5.0", - "@embedpdf/plugin-thumbnail": "^1.5.0", - "@embedpdf/plugin-tiling": "^1.5.0", - "@embedpdf/plugin-ui": "^1.5.0", - "@embedpdf/plugin-viewport": "^1.5.0", - "@embedpdf/plugin-zoom": "^1.5.0", - "preact": "^10.17.0" + "@embedpdf/core": "^2.3.0", + "@embedpdf/engines": "^2.3.0", + "@embedpdf/models": "^2.3.0", + "@embedpdf/pdfium": "^2.3.0", + "@embedpdf/plugin-annotation": "^2.3.0", + "@embedpdf/plugin-attachment": "^2.3.0", + "@embedpdf/plugin-bookmark": "^2.3.0", + "@embedpdf/plugin-capture": "^2.3.0", + "@embedpdf/plugin-commands": "^2.3.0", + "@embedpdf/plugin-document-manager": "^2.3.0", + "@embedpdf/plugin-export": "^2.3.0", + "@embedpdf/plugin-fullscreen": "^2.3.0", + "@embedpdf/plugin-history": "^2.3.0", + "@embedpdf/plugin-i18n": "^2.3.0", + "@embedpdf/plugin-interaction-manager": "^2.3.0", + "@embedpdf/plugin-pan": "^2.3.0", + "@embedpdf/plugin-print": "^2.3.0", + "@embedpdf/plugin-redaction": "^2.3.0", + "@embedpdf/plugin-render": "^2.3.0", + "@embedpdf/plugin-rotate": "^2.3.0", + "@embedpdf/plugin-scroll": "^2.3.0", + "@embedpdf/plugin-search": "^2.3.0", + "@embedpdf/plugin-selection": "^2.3.0", + "@embedpdf/plugin-spread": "^2.3.0", + "@embedpdf/plugin-thumbnail": "^2.3.0", + "@embedpdf/plugin-tiling": "^2.3.0", + "@embedpdf/plugin-ui": "^2.3.0", + "@embedpdf/plugin-viewport": "^2.3.0", + "@embedpdf/plugin-zoom": "^2.3.0", + "preact": "^10.17.0", + "tailwind-merge": "^3.4.0" } }, "node_modules/emoji-regex": { @@ -6503,13 +6513,13 @@ "license": "MIT" }, "node_modules/enhanced-resolve": { - "version": "5.18.4", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz", - "integrity": "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==", + "version": "5.20.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.0.tgz", + "integrity": "sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ==", "license": "MIT", "dependencies": { "graceful-fs": "^4.2.4", - "tapable": "^2.2.0" + "tapable": "^2.3.0" }, "engines": { "node": ">=10.13.0" @@ -6635,34 +6645,30 @@ } }, "node_modules/eslint": { - "version": "9.39.2", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", - "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.0.2.tgz", + "integrity": "sha512-uYixubwmqJZH+KLVYIVKY1JQt7tysXhtj21WSvjcSmU5SVNzMus1bgLe+pAt816yQ8opKfheVVoPLqvVMGejYw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", - "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.1", - "@eslint/config-helpers": "^0.4.2", - "@eslint/core": "^0.17.0", - "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.39.2", - "@eslint/plugin-kit": "^0.4.1", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.2", + "@eslint/config-helpers": "^0.5.2", + "@eslint/core": "^1.1.0", + "@eslint/plugin-kit": "^0.6.0", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", - "ajv": "^6.12.4", - "chalk": "^4.0.0", + "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.4.0", - "eslint-visitor-keys": "^4.2.1", - "espree": "^10.4.0", - "esquery": "^1.5.0", + "eslint-scope": "^9.1.1", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.1.1", + "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", @@ -6672,8 +6678,7 @@ "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", + "minimatch": "^10.2.1", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, @@ -6681,7 +6686,7 @@ "eslint": "bin/eslint.js" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://eslint.org/donate" @@ -6712,30 +6717,32 @@ } }, "node_modules/eslint-scope": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", - "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "version": "9.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.1.tgz", + "integrity": "sha512-GaUN0sWim5qc8KVErfPBWmc31LEsOkrUJbvJZV+xuL3u2phMUK4HIvXlWAakfC8W4nzlK+chPEAkYOYb5ZScIw==", "dev": true, "license": "BSD-2-Clause", "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" } }, "node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", "dev": true, "license": "Apache-2.0", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" @@ -6745,21 +6752,22 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/espree": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", - "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.1.1.tgz", + "integrity": "sha512-AVHPqQoZYc+RUM4/3Ly5udlZY/U4LS8pIG05jEjWM2lQMU/oaZ7qshzAl2YP1tfNmXfftH3ohurfwNAug+MnsQ==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "acorn": "^8.15.0", + "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.1" + "eslint-visitor-keys": "^5.0.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" @@ -6779,10 +6787,11 @@ } }, "node_modules/esrap": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.1.tgz", - "integrity": "sha512-GiYWG34AN/4CUyaWAgunGt0Rxvr1PTMlGC0vvEov/uOQYWne2bpN03Um+k8jT+q3op33mKouP2zeJ6OlM+qeUg==", + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.2.tgz", + "integrity": "sha512-zA6497ha+qKvoWIK+WM9NAh5ni17sKZKhbS5B3PoYbBvaYHZWoS33zmFybmyqpn07RLUxSmn+RCls2/XF+d0oQ==", "license": "MIT", + "peer": true, "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" } @@ -7003,7 +7012,6 @@ "integrity": "sha512-Pkp8m55GjxBLnhBoT6OXdMvfRr4TjMAKLvFM566zlIryq5plbhaTmLAJWTGR0EkRwLjEte1lCOG9MxF1ipJrOg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "tabbable": "^6.4.0" } @@ -7221,12 +7229,12 @@ } }, "node_modules/glob/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^2.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -7236,9 +7244,9 @@ } }, "node_modules/globals": { - "version": "17.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-17.0.0.tgz", - "integrity": "sha512-gv5BeD2EssA793rlFWVPMMCqefTlpusw6/2TbAVMy0FzcG8wKJn4O+NqJ4+XWmmwrayJgw5TzrmWjFgmz1XPqw==", + "version": "17.4.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.4.0.tgz", + "integrity": "sha512-hjrNztw/VajQwOLsMNT1cbJiH2muO3OROCHnbehc8eY5JyD2gqz4AcMHPqgaOR59DjgUjYAYLeH699g/eWi2jw==", "dev": true, "license": "MIT", "engines": { @@ -7273,15 +7281,6 @@ "integrity": "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==", "license": "MIT" }, - "node_modules/hammerjs": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/hammerjs/-/hammerjs-2.0.8.tgz", - "integrity": "sha512-tSQXBXS/MWQOn/RKckawJ61vvsDpCom87JgxiYdGwHdOa0ht0vzUWDlfioofFCRU0L+6NGDt6XzbgoJvZkMeRQ==", - "license": "MIT", - "engines": { - "node": ">=0.8.0" - } - }, "node_modules/handlebars": { "version": "4.7.8", "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", @@ -7562,9 +7561,9 @@ } }, "node_modules/i18next": { - "version": "25.7.3", - "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.7.3.tgz", - "integrity": "sha512-2XaT+HpYGuc2uTExq9TVRhLsso+Dxym6PWaKpn36wfBmTI779OQ7iP/XaZHzrnGyzU4SHpFrTYLKfVyBfAhVNA==", + "version": "25.8.13", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.8.13.tgz", + "integrity": "sha512-E0vzjBY1yM+nsFrtgkjLhST2NBkirkvOVoQa0MSldhsuZ3jUge7ZNpuwG0Cfc74zwo5ZwRzg3uOgT+McBn32iA==", "funding": [ { "type": "individual", @@ -7593,9 +7592,9 @@ } }, "node_modules/i18next-browser-languagedetector": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.0.tgz", - "integrity": "sha512-P+3zEKLnOF0qmiesW383vsLdtQVyKtCNA9cjSoKCppTKPQVfKd2W8hbVo5ZhNJKDqeM7BOcvNoKJOjpHh4Js9g==", + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.1.tgz", + "integrity": "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.23.2" @@ -7664,23 +7663,6 @@ "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", "license": "MIT" }, - "node_modules/import-fresh": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -7865,6 +7847,7 @@ "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "^1.0.6" } @@ -7983,21 +7966,6 @@ "node": ">=10" } }, - "node_modules/istanbul-lib-source-maps": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", - "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.23", - "debug": "^4.1.1", - "istanbul-lib-coverage": "^3.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/istanbul-reports": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", @@ -8048,32 +8016,19 @@ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "license": "MIT" }, - "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", - "dev": true, - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, "node_modules/jsdom": { - "version": "27.4.0", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.4.0.tgz", - "integrity": "sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==", + "version": "28.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.1.0.tgz", + "integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@acemir/cssom": "^0.9.28", - "@asamuzakjp/dom-selector": "^6.7.6", - "@exodus/bytes": "^1.6.0", - "cssstyle": "^5.3.4", - "data-urls": "^6.0.0", + "@acemir/cssom": "^0.9.31", + "@asamuzakjp/dom-selector": "^6.8.1", + "@bramus/specificity": "^2.4.2", + "@exodus/bytes": "^1.11.0", + "cssstyle": "^6.0.1", + "data-urls": "^7.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", "http-proxy-agent": "^7.0.2", @@ -8083,11 +8038,11 @@ "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.0", + "undici": "^7.21.0", "w3c-xmlserializer": "^5.0.0", - "webidl-conversions": "^8.0.0", - "whatwg-mimetype": "^4.0.0", - "whatwg-url": "^15.1.0", - "ws": "^8.18.3", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0", "xml-name-validator": "^5.0.0" }, "engines": { @@ -8137,20 +8092,19 @@ } }, "node_modules/jspdf": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.0.0.tgz", - "integrity": "sha512-w12U97Z6edKd2tXDn3LzTLg7C7QLJlx0BPfM3ecjK2BckUl9/81vZ+r5gK4/3KQdhAcEZhENUxRhtgYBj75MqQ==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.2.0.tgz", + "integrity": "sha512-hR/hnRevAXXlrjeqU5oahOE+Ln9ORJUB5brLHHqH67A+RBQZuFr5GkbI9XQI8OUFSEezKegsi45QRpc4bGj75Q==", "license": "MIT", - "peer": true, "dependencies": { - "@babel/runtime": "^7.28.4", + "@babel/runtime": "^7.28.6", "fast-png": "^6.2.0", "fflate": "^0.8.1" }, "optionalDependencies": { "canvg": "^3.0.11", "core-js": "^3.6.0", - "dompurify": "^3.2.4", + "dompurify": "^3.3.1", "html2canvas": "^1.0.0-rc.5" } }, @@ -8252,19 +8206,20 @@ "integrity": "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==" }, "node_modules/langium": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/langium/-/langium-3.3.1.tgz", - "integrity": "sha512-QJv/h939gDpvT+9SiLVlY7tZC3xB2qK57v0J04Sh9wpMb6MP1q8gB21L3WIo8T5P1MSMg3Ep14L7KkDCFG3y4w==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/langium/-/langium-4.2.1.tgz", + "integrity": "sha512-zu9QWmjpzJcomzdJQAHgDVhLGq5bLosVak1KVa40NzQHXfqr4eAHupvnPOVXEoLkg6Ocefvf/93d//SB7du4YQ==", "license": "MIT", "dependencies": { - "chevrotain": "~11.0.3", - "chevrotain-allstar": "~0.3.0", + "chevrotain": "~11.1.1", + "chevrotain-allstar": "~0.3.1", "vscode-languageserver": "~9.0.1", "vscode-languageserver-textdocument": "~1.0.11", - "vscode-uri": "~3.0.8" + "vscode-uri": "~3.1.0" }, "engines": { - "node": ">=16.0.0" + "node": ">=20.10.0", + "npm": ">=10.2.3" } }, "node_modules/layout-base": { @@ -8345,9 +8300,9 @@ } }, "node_modules/lightningcss": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", - "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz", + "integrity": "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==", "license": "MPL-2.0", "dependencies": { "detect-libc": "^2.0.3" @@ -8360,23 +8315,23 @@ "url": "https://opencollective.com/parcel" }, "optionalDependencies": { - "lightningcss-android-arm64": "1.30.2", - "lightningcss-darwin-arm64": "1.30.2", - "lightningcss-darwin-x64": "1.30.2", - "lightningcss-freebsd-x64": "1.30.2", - "lightningcss-linux-arm-gnueabihf": "1.30.2", - "lightningcss-linux-arm64-gnu": "1.30.2", - "lightningcss-linux-arm64-musl": "1.30.2", - "lightningcss-linux-x64-gnu": "1.30.2", - "lightningcss-linux-x64-musl": "1.30.2", - "lightningcss-win32-arm64-msvc": "1.30.2", - "lightningcss-win32-x64-msvc": "1.30.2" + "lightningcss-android-arm64": "1.31.1", + "lightningcss-darwin-arm64": "1.31.1", + "lightningcss-darwin-x64": "1.31.1", + "lightningcss-freebsd-x64": "1.31.1", + "lightningcss-linux-arm-gnueabihf": "1.31.1", + "lightningcss-linux-arm64-gnu": "1.31.1", + "lightningcss-linux-arm64-musl": "1.31.1", + "lightningcss-linux-x64-gnu": "1.31.1", + "lightningcss-linux-x64-musl": "1.31.1", + "lightningcss-win32-arm64-msvc": "1.31.1", + "lightningcss-win32-x64-msvc": "1.31.1" } }, "node_modules/lightningcss-android-arm64": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz", - "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==", + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.31.1.tgz", + "integrity": "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==", "cpu": [ "arm64" ], @@ -8394,9 +8349,9 @@ } }, "node_modules/lightningcss-darwin-arm64": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz", - "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==", + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.31.1.tgz", + "integrity": "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==", "cpu": [ "arm64" ], @@ -8414,9 +8369,9 @@ } }, "node_modules/lightningcss-darwin-x64": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz", - "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==", + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.31.1.tgz", + "integrity": "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==", "cpu": [ "x64" ], @@ -8434,9 +8389,9 @@ } }, "node_modules/lightningcss-freebsd-x64": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz", - "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==", + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.31.1.tgz", + "integrity": "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==", "cpu": [ "x64" ], @@ -8454,9 +8409,9 @@ } }, "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz", - "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==", + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.31.1.tgz", + "integrity": "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==", "cpu": [ "arm" ], @@ -8474,9 +8429,9 @@ } }, "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz", - "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==", + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.31.1.tgz", + "integrity": "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==", "cpu": [ "arm64" ], @@ -8494,9 +8449,9 @@ } }, "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz", - "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==", + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.31.1.tgz", + "integrity": "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==", "cpu": [ "arm64" ], @@ -8514,9 +8469,9 @@ } }, "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz", - "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==", + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.31.1.tgz", + "integrity": "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==", "cpu": [ "x64" ], @@ -8534,9 +8489,9 @@ } }, "node_modules/lightningcss-linux-x64-musl": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz", - "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==", + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.31.1.tgz", + "integrity": "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==", "cpu": [ "x64" ], @@ -8554,9 +8509,9 @@ } }, "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz", - "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==", + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.31.1.tgz", + "integrity": "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==", "cpu": [ "arm64" ], @@ -8574,9 +8529,9 @@ } }, "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz", - "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==", + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.31.1.tgz", + "integrity": "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==", "cpu": [ "x64" ], @@ -8622,19 +8577,18 @@ } }, "node_modules/lint-staged": { - "version": "16.2.7", - "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-16.2.7.tgz", - "integrity": "sha512-lDIj4RnYmK7/kXMya+qJsmkRFkGolciXjrsZ6PC25GdTfWOAWetR0ZbsNXRAj1EHHImRSalc+whZFg56F5DVow==", + "version": "16.3.1", + "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-16.3.1.tgz", + "integrity": "sha512-bqvvquXzFBAlSbluugR4KXAe4XnO/QZcKVszpkBtqLWa2KEiVy8n6Xp38OeUbv/gOJOX4Vo9u5pFt/ADvbm42Q==", "dev": true, "license": "MIT", "dependencies": { - "commander": "^14.0.2", + "commander": "^14.0.3", "listr2": "^9.0.5", "micromatch": "^4.0.8", - "nano-spawn": "^2.0.0", - "pidtree": "^0.6.0", "string-argv": "^0.3.2", - "yaml": "^2.8.1" + "tinyexec": "^1.0.2", + "yaml": "^2.8.2" }, "bin": { "lint-staged": "bin/lint-staged.js" @@ -8720,11 +8674,43 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/lit": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/lit/-/lit-3.3.2.tgz", + "integrity": "sha512-NF9zbsP79l4ao2SNrH3NkfmFgN/hBYSQo90saIVI1o5GpjAdCPVstVzO1MrLOakHoEhYkrtRjPK6Ob521aoYWQ==", + "license": "BSD-3-Clause", + "dependencies": { + "@lit/reactive-element": "^2.1.0", + "lit-element": "^4.2.0", + "lit-html": "^3.3.0" + } + }, + "node_modules/lit-element": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/lit-element/-/lit-element-4.2.2.tgz", + "integrity": "sha512-aFKhNToWxoyhkNDmWZwEva2SlQia+jfG0fjIWV//YeTaWrVnOxD89dPKfigCUspXFmjzOEUQpOkejH5Ly6sG0w==", + "license": "BSD-3-Clause", + "dependencies": { + "@lit-labs/ssr-dom-shim": "^1.5.0", + "@lit/reactive-element": "^2.1.0", + "lit-html": "^3.3.0" + } + }, + "node_modules/lit-html": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/lit-html/-/lit-html-3.3.2.tgz", + "integrity": "sha512-Qy9hU88zcmaxBXcc10ZpdK7cOLXvXpRoBxERdtqV9QOrfpMZZ6pSYP91LhpPtap3sFMUiL7Tw2RImbe0Al2/kw==", + "license": "BSD-3-Clause", + "dependencies": { + "@types/trusted-types": "^2.0.2" + } + }, "node_modules/locate-character": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/locate-path": { "version": "6.0.0", @@ -8743,22 +8729,15 @@ } }, "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", "license": "MIT" }, "node_modules/lodash-es": { - "version": "4.17.22", - "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.22.tgz", - "integrity": "sha512-XEawp1t0gxSi9x01glktRZ5HDy0HXqrM0x5pXQM98EaI0NxO6jVM7omDOxsuEo5UIASAnm2bRp1Jt/e0a2XU8Q==", - "license": "MIT" - }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true, + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz", + "integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==", "license": "MIT" }, "node_modules/log-update": { @@ -8842,6 +8821,7 @@ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", "license": "MIT", + "peer": true, "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, @@ -8849,17 +8829,10 @@ "loose-envify": "cli.js" } }, - "node_modules/loupe": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", - "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", - "dev": true, - "license": "MIT" - }, "node_modules/lru-cache": { - "version": "11.2.4", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", - "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", "dev": true, "license": "BlueOak-1.0.0", "engines": { @@ -8867,9 +8840,9 @@ } }, "node_modules/lucide": { - "version": "0.546.0", - "resolved": "https://registry.npmjs.org/lucide/-/lucide-0.546.0.tgz", - "integrity": "sha512-YJES3MM/naQS4wJ0JLzTY3anooqWw5iTsPCffHbSMncdxJT2C5tmkCDAwIHMHG8TMtaQcu40KREMPss2qH/+yA==", + "version": "0.575.0", + "resolved": "https://registry.npmjs.org/lucide/-/lucide-0.575.0.tgz", + "integrity": "sha512-+xwqZpvrqPioU8bSH49zH2xARfnKyZgIjdnfbex0CrURB3q4wNFhinYN1Z9Q3lE16Q/6N9iEXnStvyS3c70RKw==", "license": "ISC" }, "node_modules/lz-string": { @@ -8892,15 +8865,15 @@ } }, "node_modules/magicast": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", - "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.2.tgz", + "integrity": "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.25.4", - "@babel/types": "^7.25.4", - "source-map-js": "^1.2.0" + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" } }, "node_modules/make-dir": { @@ -8927,11 +8900,10 @@ "license": "MIT" }, "node_modules/markdown-it": { - "version": "14.1.0", - "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz", - "integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==", + "version": "14.1.1", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz", + "integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==", "license": "MIT", - "peer": true, "dependencies": { "argparse": "^2.0.1", "entities": "^4.4.0", @@ -9084,14 +9056,14 @@ "license": "MIT" }, "node_modules/mermaid": { - "version": "11.12.2", - "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.12.2.tgz", - "integrity": "sha512-n34QPDPEKmaeCG4WDMGy0OT6PSyxKCfy2pJgShP+Qow2KLrvWjclwbc3yXfSIf4BanqWEhQEpngWwNp/XhZt6w==", + "version": "11.12.3", + "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.12.3.tgz", + "integrity": "sha512-wN5ZSgJQIC+CHJut9xaKWsknLxaFBwCPwPkGTSUYrTiHORWvpT8RxGk849HPnpUAQ+/9BPRqYb80jTpearrHzQ==", "license": "MIT", "dependencies": { "@braintree/sanitize-url": "^7.1.1", "@iconify/utils": "^3.0.1", - "@mermaid-js/parser": "^0.6.3", + "@mermaid-js/parser": "^1.0.0", "@types/d3": "^7.4.3", "cytoscape": "^3.29.3", "cytoscape-cose-bilkent": "^4.1.0", @@ -9103,7 +9075,7 @@ "dompurify": "^3.2.5", "katex": "^0.16.22", "khroma": "^2.1.0", - "lodash-es": "^4.17.21", + "lodash-es": "^4.17.23", "marked": "^16.2.1", "roughjs": "^4.6.6", "stylis": "^4.3.6", @@ -9111,6 +9083,12 @@ "uuid": "^11.1.0" } }, + "node_modules/microdiff": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/microdiff/-/microdiff-1.5.0.tgz", + "integrity": "sha512-Drq+/THMvDdzRYrK0oxJmOKiC24ayUV8ahrt8l3oRK51PWt6gdtrIGrlIH3pT/lFh1z93FbAcidtsHcWbnRz8Q==", + "license": "MIT" + }, "node_modules/micromark-util-character": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", @@ -9268,16 +9246,19 @@ "license": "MIT" }, "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^1.1.7" + "brace-expansion": "^5.0.2" }, "engines": { - "node": "*" + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/minimist": { @@ -9342,19 +9323,6 @@ "dev": true, "license": "MIT" }, - "node_modules/nano-spawn": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/nano-spawn/-/nano-spawn-2.0.0.tgz", - "integrity": "sha512-tacvGzUY5o2D8CBh2rrwxyNojUsZNU2zjNTzKQrkgGJQTbGAfArVWXSKMBokBeeg6C7OLRGUEyoFlYbfeWQIqw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20.17" - }, - "funding": { - "url": "https://github.com/sindresorhus/nano-spawn?sponsor=1" - } - }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -9438,6 +9406,13 @@ "node": ">= 6.13.0" } }, + "node_modules/node-readable-to-web-readable-stream": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/node-readable-to-web-readable-stream/-/node-readable-to-web-readable-stream-0.4.2.tgz", + "integrity": "sha512-/cMZNI34v//jUTrI+UIo4ieHAB5EZRY/+7OmXZgBxaWBMcW2tGdceIw06RFxWxrKZ5Jp3sI2i5TsRo+CBhtVLQ==", + "license": "MIT", + "optional": true + }, "node_modules/node-stdlib-browser": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/node-stdlib-browser/-/node-stdlib-browser-1.3.1.tgz", @@ -9594,6 +9569,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/onetime": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", @@ -9724,19 +9710,6 @@ "integrity": "sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==", "license": "MIT" }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/parse-asn1": { "version": "5.1.9", "resolved": "https://registry.npmjs.org/parse-asn1/-/parse-asn1-5.1.9.tgz", @@ -9847,16 +9820,6 @@ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "license": "MIT" }, - "node_modules/pathval": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", - "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.16" - } - }, "node_modules/pbkdf2": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/pbkdf2/-/pbkdf2-3.1.5.tgz", @@ -9897,15 +9860,16 @@ } }, "node_modules/pdfjs-dist": { - "version": "5.4.530", - "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.530.tgz", - "integrity": "sha512-r1hWsSIGGmyYUAHR26zSXkxYWLXLMd6AwqcaFYG9YUZ0GBf5GvcjJSeo512tabM4GYFhxhl5pMCmPr7Q72Rq2Q==", + "version": "5.4.624", + "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.624.tgz", + "integrity": "sha512-sm6TxKTtWv1Oh6n3C6J6a8odejb5uO4A4zo/2dgkHuC0iu8ZMAXOezEODkVaoVp8nX1Xzr+0WxFJJmUr45hQzg==", "license": "Apache-2.0", "engines": { "node": ">=20.16.0 || >=22.3.0" }, "optionalDependencies": { - "@napi-rs/canvas": "^0.1.84" + "@napi-rs/canvas": "^0.1.88", + "node-readable-to-web-readable-stream": "^0.4.2" } }, "node_modules/pdfkit": { @@ -9953,17 +9917,16 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/pidtree": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.6.0.tgz", - "integrity": "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==", - "dev": true, - "license": "MIT", - "bin": { - "pidtree": "bin/pidtree.js" + "node_modules/pixelmatch": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pixelmatch/-/pixelmatch-7.1.0.tgz", + "integrity": "sha512-1wrVzJ2STrpmONHKBy228LM1b84msXDUoAzVEl0R8Mz4Ce6EPr+IVtxm8+yvrqLYMHswREkjYFaMxnyGnaY3Ng==", + "license": "ISC", + "dependencies": { + "pngjs": "^7.0.0" }, - "engines": { - "node": ">=0.10" + "bin": { + "pixelmatch": "bin/pixelmatch" } }, "node_modules/pkg-dir": { @@ -9995,6 +9958,15 @@ "resolved": "https://registry.npmjs.org/png-js/-/png-js-1.0.0.tgz", "integrity": "sha512-k+YsbhpA9e+EFfKjTCH3VW6aoKlyNYI6NYdTfDL4CIvFnvsuO84ttonmZE7rc+v23SLTH8XX+5w/Ak9v0xGY4g==" }, + "node_modules/pngjs": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz", + "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==", + "license": "MIT", + "engines": { + "node": ">=14.19.0" + } + }, "node_modules/points-on-curve": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-0.2.0.tgz", @@ -10022,9 +9994,9 @@ } }, "node_modules/postal-mime": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-2.7.1.tgz", - "integrity": "sha512-0VslL0CLSV7PBmglwWR8eCGC5fgsdVictjOG4PEA+vvA0+QJF5SC0tV018CbvAcW4XbpbMIJNd91Dt8vTa9kbA==", + "version": "2.7.3", + "resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-2.7.3.tgz", + "integrity": "sha512-MjhXadAJaWgYzevi46+3kLak8y6gbg0ku14O1gO/LNOuay8dO+1PtcSGvAdgDR0DoIsSaiIA8y/Ddw6MnrO0Tw==", "license": "MIT-0" }, "node_modules/postcss": { @@ -10060,7 +10032,6 @@ "resolved": "https://registry.npmjs.org/preact/-/preact-10.28.2.tgz", "integrity": "sha512-lbteaWGzGHdlIuiJ0l2Jq454m6kcpI1zNje6d8MlGAFlYvP2GO4ibnat7P74Esfz4sPTdM6UxtTwh/d3pwM9JA==", "license": "MIT", - "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/preact" @@ -10077,9 +10048,9 @@ } }, "node_modules/prettier": { - "version": "3.7.4", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.4.tgz", - "integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==", + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "dev": true, "license": "MIT", "bin": { @@ -10188,9 +10159,9 @@ } }, "node_modules/qs": { - "version": "6.14.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", - "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -10312,9 +10283,9 @@ } }, "node_modules/readdir-glob/node_modules/minimatch": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", - "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" @@ -10399,16 +10370,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/restore-cursor": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", @@ -10432,6 +10393,66 @@ "integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==", "license": "MIT" }, + "node_modules/rete": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/rete/-/rete-2.0.6.tgz", + "integrity": "sha512-kPmlKCGFES2VWtY7Y7SCB8ZeXRMsgX5deza9cu4OwmfM/ZUimd461kC3hRyccoyVxE4POlHUx0gg2jcGfusHFg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.21.0" + } + }, + "node_modules/rete-area-plugin": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/rete-area-plugin/-/rete-area-plugin-2.1.5.tgz", + "integrity": "sha512-iquEvwkQlcsO4cmgM3Z37TG0AWaE536dfA+lCJAze5YJzVx4RBaViUCqdB4dUA/utSytpBCkiDC4D3ztM9akGQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.21.0" + }, + "peerDependencies": { + "rete": "^2.0.0" + } + }, + "node_modules/rete-connection-plugin": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/rete-connection-plugin/-/rete-connection-plugin-2.0.5.tgz", + "integrity": "sha512-KFtlOyEJRc0y9STVgo2T+t+j9u5fxiTxbyzPbMCm0uqncb3b8d2ABDIzvWoNo5zQAh2Oz/OvlUovupbzrGzpSg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.21.0" + }, + "peerDependencies": { + "rete": "^2.0.1", + "rete-area-plugin": "^2.0.0" + } + }, + "node_modules/rete-engine": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/rete-engine/-/rete-engine-2.1.1.tgz", + "integrity": "sha512-RrIQDQycD5QZlDYCG1FKu2GLOKTgeIPLxKefnTVoOk7xYAyzlQ4HhXRa+ldsGaFAZzQHOXPgbkqis9ZqSB36MA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.21.0" + }, + "peerDependencies": { + "rete": "^2.0.1" + } + }, + "node_modules/rete-render-utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/rete-render-utils/-/rete-render-utils-2.0.3.tgz", + "integrity": "sha512-Oz4W2PNayHocRvlzadb5BCNf+tDzJ8RhTwB3ucBPCdCLKZ974wWDiTSCRfA287L2hmHVzRfBdyAwC03K9eP+4g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.21.0" + }, + "peerDependencies": { + "rete": "^2.0.0", + "rete-area-plugin": "^2.0.0" + } + }, "node_modules/rfdc": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", @@ -10533,11 +10554,10 @@ "license": "Unlicense" }, "node_modules/rollup": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.1.tgz", - "integrity": "sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -10549,31 +10569,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.55.1", - "@rollup/rollup-android-arm64": "4.55.1", - "@rollup/rollup-darwin-arm64": "4.55.1", - "@rollup/rollup-darwin-x64": "4.55.1", - "@rollup/rollup-freebsd-arm64": "4.55.1", - "@rollup/rollup-freebsd-x64": "4.55.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.55.1", - "@rollup/rollup-linux-arm-musleabihf": "4.55.1", - "@rollup/rollup-linux-arm64-gnu": "4.55.1", - "@rollup/rollup-linux-arm64-musl": "4.55.1", - "@rollup/rollup-linux-loong64-gnu": "4.55.1", - "@rollup/rollup-linux-loong64-musl": "4.55.1", - "@rollup/rollup-linux-ppc64-gnu": "4.55.1", - "@rollup/rollup-linux-ppc64-musl": "4.55.1", - "@rollup/rollup-linux-riscv64-gnu": "4.55.1", - "@rollup/rollup-linux-riscv64-musl": "4.55.1", - "@rollup/rollup-linux-s390x-gnu": "4.55.1", - "@rollup/rollup-linux-x64-gnu": "4.55.1", - "@rollup/rollup-linux-x64-musl": "4.55.1", - "@rollup/rollup-openbsd-x64": "4.55.1", - "@rollup/rollup-openharmony-arm64": "4.55.1", - "@rollup/rollup-win32-arm64-msvc": "4.55.1", - "@rollup/rollup-win32-ia32-msvc": "4.55.1", - "@rollup/rollup-win32-x64-gnu": "4.55.1", - "@rollup/rollup-win32-x64-msvc": "4.55.1", + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" } }, @@ -10657,6 +10677,7 @@ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" } @@ -10906,11 +10927,10 @@ } }, "node_modules/sortablejs": { - "version": "1.15.6", - "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.6.tgz", - "integrity": "sha512-aNfiuwMEpfBM/CN6LY0ibyhxPfPbyFeBTYJKCvzkJ2GkUpazIt3H+QIPAMHwqQ7tMKaHz1Qj+rJJCqljnf4p3A==", - "license": "MIT", - "peer": true + "version": "1.15.7", + "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.7.tgz", + "integrity": "sha512-Kk8wLQPlS+yi1ZEf48a4+fzHa4yxjC30M/Sr2AnQu+f/MPwvvX9XjZ6OWejiz8crBsLwSq8GHqaxaET7u6ux0A==", + "license": "MIT" }, "node_modules/source-map": { "version": "0.6.1", @@ -11183,39 +11203,6 @@ "url": "https://github.com/chalk/ansi-regex?sponsor=1" } }, - "node_modules/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/strip-literal": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", - "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "js-tokens": "^9.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/strip-literal/node_modules/js-tokens": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", - "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", - "dev": true, - "license": "MIT" - }, "node_modules/stylis": { "version": "4.3.6", "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.6.tgz", @@ -11262,9 +11249,9 @@ } }, "node_modules/svelte": { - "version": "5.46.1", - "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.46.1.tgz", - "integrity": "sha512-ynjfCHD3nP2el70kN5Pmg37sSi0EjOm9FgHYQdC4giWG/hzO3AatzXXJJgP305uIhGQxSufJLuYWtkY8uK/8RA==", + "version": "5.53.6", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.53.6.tgz", + "integrity": "sha512-lP5DGF3oDDI9fhHcSpaBiJEkFLuS16h92DhM1L5K1lFm0WjOmUh1i2sNkBBk8rkxJRpob0dBE75jRfUzGZUOGA==", "license": "MIT", "peer": true, "dependencies": { @@ -11272,13 +11259,14 @@ "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", + "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", - "aria-query": "^5.3.1", + "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", - "devalue": "^5.5.0", + "devalue": "^5.6.3", "esm-env": "^1.2.1", - "esrap": "^2.2.1", + "esrap": "^2.2.2", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", @@ -11289,10 +11277,11 @@ } }, "node_modules/svelte/node_modules/aria-query": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", - "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", "license": "Apache-2.0", + "peer": true, "engines": { "node": ">= 0.4" } @@ -11321,10 +11310,20 @@ "dev": true, "license": "MIT" }, + "node_modules/tailwind-merge": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz", + "integrity": "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, "node_modules/tailwindcss": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", - "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz", + "integrity": "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==", "license": "MIT" }, "node_modules/tapable": { @@ -11352,11 +11351,10 @@ } }, "node_modules/terser": { - "version": "5.44.1", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.44.1.tgz", - "integrity": "sha512-t/R3R/n0MSwnnazuPpPNVO60LX0SKL45pyl9YlvxIdkH0Of7D5qM2EVe+yASRIlY5pZ73nclYJfNANGWPwFDZw==", + "version": "5.46.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.46.0.tgz", + "integrity": "sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg==", "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", @@ -11377,9 +11375,9 @@ "license": "MIT" }, "node_modules/tesseract.js": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/tesseract.js/-/tesseract.js-6.0.1.tgz", - "integrity": "sha512-/sPvMvrCtgxnNRCjbTYbr7BRu0yfWDsMZQ2a/T5aN/L1t8wUQN6tTWv6p6FwzpoEBA0jrN2UD2SX4QQFRdoDbA==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/tesseract.js/-/tesseract.js-7.0.0.tgz", + "integrity": "sha512-exPBkd+z+wM1BuMkx/Bjv43OeLBxhL5kKWsz/9JY+DXcXdiBjiAch0V49QR3oAJqCaL5qURE0vx9Eo+G5YE7mA==", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { @@ -11389,58 +11387,17 @@ "node-fetch": "^2.6.9", "opencollective-postinstall": "^2.0.3", "regenerator-runtime": "^0.13.3", - "tesseract.js-core": "^6.0.0", - "wasm-feature-detect": "^1.2.11", + "tesseract.js-core": "^7.0.0", + "wasm-feature-detect": "^1.8.0", "zlibjs": "^0.3.1" } }, "node_modules/tesseract.js-core": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/tesseract.js-core/-/tesseract.js-core-6.1.2.tgz", - "integrity": "sha512-pv4GjmramjdObhDyR1q85Td8X60Puu/lGQn7Kw2id05LLgHhAcWgnz6xSdMCSxBMWjQDmMyDXPTC2aqADdpiow==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/tesseract.js-core/-/tesseract.js-core-7.0.0.tgz", + "integrity": "sha512-WnNH518NzmbSq9zgTPeoF8c+xmilS8rFIl1YKbk/ptuuc7p6cLNELNuPAzcmsYw450ca6bLa8j3t0VAtq435Vw==", "license": "Apache-2.0" }, - "node_modules/test-exclude": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.1.tgz", - "integrity": "sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==", - "dev": true, - "license": "ISC", - "dependencies": { - "@istanbuljs/schema": "^0.1.2", - "glob": "^10.4.1", - "minimatch": "^9.0.4" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/test-exclude/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/test-exclude/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/text-decoder": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.3.tgz", @@ -11548,7 +11505,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -11556,30 +11512,10 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/tinypool": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", - "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.0.0 || >=20.0.0" - } - }, "node_modules/tinyrainbow": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", - "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tinyspy": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", - "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", "dev": true, "license": "MIT", "engines": { @@ -11749,7 +11685,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -11759,16 +11694,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.51.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.51.0.tgz", - "integrity": "sha512-jh8ZuM5oEh2PSdyQG9YAEM1TCGuWenLSuSUhf/irbVUNW9O5FhbFVONviN2TgMTBnUmyHv7E56rYnfLZK6TkiA==", + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.56.1.tgz", + "integrity": "sha512-U4lM6pjmBX7J5wk4szltF7I1cGBHXZopnAXCMXb3+fZ3B/0Z3hq3wS/CCUB2NZBNAExK92mCU2tEohWuwVMsDQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.51.0", - "@typescript-eslint/parser": "8.51.0", - "@typescript-eslint/typescript-estree": "8.51.0", - "@typescript-eslint/utils": "8.51.0" + "@typescript-eslint/eslint-plugin": "8.56.1", + "@typescript-eslint/parser": "8.56.1", + "@typescript-eslint/typescript-estree": "8.56.1", + "@typescript-eslint/utils": "8.56.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -11778,7 +11713,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, @@ -11808,6 +11743,16 @@ "node": ">=0.8.0" } }, + "node_modules/undici": { + "version": "7.22.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.22.0.tgz", + "integrity": "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", @@ -12036,11 +11981,10 @@ } }, "node_modules/vite": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz", - "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -12110,29 +12054,6 @@ } } }, - "node_modules/vite-node": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", - "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cac": "^6.7.14", - "debug": "^4.4.1", - "es-module-lexer": "^1.7.0", - "pathe": "^2.0.3", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" - }, - "bin": { - "vite-node": "vite-node.mjs" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, "node_modules/vite-plugin-compression": { "version": "0.5.1", "resolved": "https://registry.npmjs.org/vite-plugin-compression/-/vite-plugin-compression-0.5.1.tgz", @@ -12650,14 +12571,14 @@ } }, "node_modules/vite-plugin-node-polyfills": { - "version": "0.24.0", - "resolved": "https://registry.npmjs.org/vite-plugin-node-polyfills/-/vite-plugin-node-polyfills-0.24.0.tgz", - "integrity": "sha512-GA9QKLH+vIM8NPaGA+o2t8PDfFUl32J8rUp1zQfMKVJQiNkOX4unE51tR6ppl6iKw5yOrDAdSH7r/UIFLCVhLw==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/vite-plugin-node-polyfills/-/vite-plugin-node-polyfills-0.25.0.tgz", + "integrity": "sha512-rHZ324W3LhfGPxWwQb2N048TThB6nVvnipsqBUJEzh3R9xeK9KI3si+GMQxCuAcpPJBVf0LpDtJ+beYzB3/chg==", "dev": true, "license": "MIT", "dependencies": { "@rollup/plugin-inject": "^5.0.5", - "node-stdlib-browser": "^1.2.0" + "node-stdlib-browser": "^1.3.1" }, "funding": { "url": "https://github.com/sponsors/davidmyersdev" @@ -12667,19 +12588,23 @@ } }, "node_modules/vite-plugin-static-copy": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/vite-plugin-static-copy/-/vite-plugin-static-copy-3.1.4.tgz", - "integrity": "sha512-iCmr4GSw4eSnaB+G8zc2f4dxSuDjbkjwpuBLLGvQYR9IW7rnDzftnUjOH5p4RYR+d4GsiBqXRvzuFhs5bnzVyw==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/vite-plugin-static-copy/-/vite-plugin-static-copy-3.2.0.tgz", + "integrity": "sha512-g2k9z8B/1Bx7D4wnFjPLx9dyYGrqWMLTpwTtPHhcU+ElNZP2O4+4OsyaficiDClus0dzVhdGvoGFYMJxoXZ12Q==", "license": "MIT", "dependencies": { "chokidar": "^3.6.0", - "p-map": "^7.0.3", + "p-map": "^7.0.4", "picocolors": "^1.1.1", "tinyglobby": "^0.2.15" }, "engines": { "node": "^18.0.0 || >=20.0.0" }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/sapphi-red" + }, "peerDependencies": { "vite": "^5.0.0 || ^6.0.0 || ^7.0.0" } @@ -12706,7 +12631,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -13206,7 +13130,6 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -13262,52 +13185,50 @@ } }, "node_modules/vitest": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", - "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz", + "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@types/chai": "^5.2.2", - "@vitest/expect": "3.2.4", - "@vitest/mocker": "3.2.4", - "@vitest/pretty-format": "^3.2.4", - "@vitest/runner": "3.2.4", - "@vitest/snapshot": "3.2.4", - "@vitest/spy": "3.2.4", - "@vitest/utils": "3.2.4", - "chai": "^5.2.0", - "debug": "^4.4.1", - "expect-type": "^1.2.1", - "magic-string": "^0.30.17", + "@vitest/expect": "4.0.18", + "@vitest/mocker": "4.0.18", + "@vitest/pretty-format": "4.0.18", + "@vitest/runner": "4.0.18", + "@vitest/snapshot": "4.0.18", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "es-module-lexer": "^1.7.0", + "expect-type": "^1.2.2", + "magic-string": "^0.30.21", + "obug": "^2.1.1", "pathe": "^2.0.3", - "picomatch": "^4.0.2", - "std-env": "^3.9.0", + "picomatch": "^4.0.3", + "std-env": "^3.10.0", "tinybench": "^2.9.0", - "tinyexec": "^0.3.2", - "tinyglobby": "^0.2.14", - "tinypool": "^1.1.1", - "tinyrainbow": "^2.0.0", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", - "vite-node": "3.2.4", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0", "why-is-node-running": "^2.3.0" }, "bin": { "vitest": "vitest.mjs" }, "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { "@edge-runtime/vm": "*", - "@types/debug": "^4.1.12", - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "@vitest/browser": "3.2.4", - "@vitest/ui": "3.2.4", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.0.18", + "@vitest/browser-preview": "4.0.18", + "@vitest/browser-webdriverio": "4.0.18", + "@vitest/ui": "4.0.18", "happy-dom": "*", "jsdom": "*" }, @@ -13315,13 +13236,19 @@ "@edge-runtime/vm": { "optional": true }, - "@types/debug": { + "@opentelemetry/api": { "optional": true }, "@types/node": { "optional": true }, - "@vitest/browser": { + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { "optional": true }, "@vitest/ui": { @@ -13348,13 +13275,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/vitest/node_modules/tinyexec": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", - "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", - "dev": true, - "license": "MIT" - }, "node_modules/vm-browserify": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vm-browserify/-/vm-browserify-1.1.2.tgz", @@ -13406,23 +13326,22 @@ "license": "MIT" }, "node_modules/vscode-uri": { - "version": "3.0.8", - "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.0.8.tgz", - "integrity": "sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", "license": "MIT" }, "node_modules/vue": { - "version": "3.5.26", - "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.26.tgz", - "integrity": "sha512-SJ/NTccVyAoNUJmkM9KUqPcYlY+u8OVL1X5EW9RIs3ch5H2uERxyyIUI4MRxVCSOiEcupX9xNGde1tL9ZKpimA==", + "version": "3.5.29", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.29.tgz", + "integrity": "sha512-BZqN4Ze6mDQVNAni0IHeMJ5mwr8VAJ3MQC9FmprRhcBYENw+wOAAjRj8jfmN6FLl0j96OXbR+CjWhmAmM+QGnA==", "license": "MIT", - "peer": true, "dependencies": { - "@vue/compiler-dom": "3.5.26", - "@vue/compiler-sfc": "3.5.26", - "@vue/runtime-dom": "3.5.26", - "@vue/server-renderer": "3.5.26", - "@vue/shared": "3.5.26" + "@vue/compiler-dom": "3.5.29", + "@vue/compiler-sfc": "3.5.29", + "@vue/runtime-dom": "3.5.29", + "@vue/server-renderer": "3.5.29", + "@vue/shared": "3.5.29" }, "peerDependencies": { "typescript": "*" @@ -13463,27 +13382,28 @@ } }, "node_modules/whatwg-mimetype": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", - "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", "dev": true, "license": "MIT", "engines": { - "node": ">=18" + "node": ">=20" } }, "node_modules/whatwg-url": { - "version": "15.1.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-15.1.0.tgz", - "integrity": "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==", + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", "dev": true, "license": "MIT", "dependencies": { + "@exodus/bytes": "^1.11.0", "tr46": "^6.0.0", - "webidl-conversions": "^8.0.0" + "webidl-conversions": "^8.0.1" }, "engines": { - "node": ">=20" + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, "node_modules/which": { @@ -13645,32 +13565,10 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/ws": { - "version": "8.18.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", - "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, "node_modules/xlsx": { - "version": "0.20.2", - "resolved": "file:vendor/sheetjs/xlsx-0.20.2.tgz", - "integrity": "sha512-+nKZ39+nvK7Qq6i0PvWWRA4j/EkfWOtkP/YhMtupm+lJIiHxUrgTr1CcKv1nBk1rHtkRRQ3O2+Ih/q/sA+FXZA==", + "version": "0.20.3", + "resolved": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz", + "integrity": "sha512-oLDq3jw7AcLqKWH2AhCpVTZl8mf6X2YReP+Neh0SJUzV/BdZYjth94tG5toiMB1PPrYtxOCfaoUCkvtuH+3AJA==", "license": "Apache-2.0", "bin": { "xlsx": "bin/xlsx.njs" @@ -13751,7 +13649,8 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/zip-stream": { "version": "6.0.1", diff --git a/package.json b/package.json index 30c10ae..3de5ce1 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,12 @@ { "name": "bento-pdf", "private": true, - "version": "1.16.0", + "version": "2.5.0", "license": "AGPL-3.0-only", "type": "module", "scripts": { "dev": "vite", - "build": "tsc && vite build && NODE_OPTIONS='--max-old-space-size=4096' node scripts/generate-i18n-pages.mjs && node scripts/generate-sitemap.mjs", + "build": "tsc && vite build && NODE_OPTIONS='--max-old-space-size=3072' node scripts/generate-i18n-pages.mjs && node scripts/generate-sitemap.mjs", "build:with-docs": "npm run build && npm run docs:build && node scripts/include-docs-in-dist.js", "build:gzip": "COMPRESSION_MODE=g npm run build", "build:brotli": "COMPRESSION_MODE=b npm run build", @@ -37,35 +37,34 @@ "prepare": "husky" }, "devDependencies": { - "@eslint/js": "^9.39.2", + "@eslint/js": "^10.0.1", "@testing-library/dom": "^10.4.1", "@types/blob-stream": "^0.1.33", "@types/html2canvas": "^1.0.0", - "@types/pdfkit": "^0.17.3", + "@types/pdfkit": "^0.17.5", "@types/sortablejs": "^1.15.8", "@types/utif": "^3.0.6", - "@vitest/coverage-v8": "^3.2.4", - "@vitest/ui": "^3.2.4", - "eslint": "^9.39.2", + "@vitejs/plugin-basic-ssl": "^2.1.4", + "@vitest/coverage-v8": "^4.0.18", + "@vitest/ui": "^4.0.18", + "eslint": "^10.0.2", "eslint-config-prettier": "^10.1.8", - "globals": "^17.0.0", + "globals": "^17.4.0", "husky": "^9.1.7", - "jsdom": "^27.0.1", - "lint-staged": "^16.2.7", - "prettier": "^3.6.2", + "jsdom": "^28.1.0", + "lint-staged": "^16.3.1", + "prettier": "^3.8.1", "typescript": "~5.9.3", - "typescript-eslint": "^8.51.0", - "vite": "^7.1.11", + "typescript-eslint": "^8.56.1", + "vite": "^7.3.1", "vite-plugin-compression": "^0.5.1", "vite-plugin-handlebars": "^2.0.0", - "vite-plugin-node-polyfills": "^0.24.0", + "vite-plugin-node-polyfills": "^0.25.0", "vitepress": "^1.6.4", - "vitest": "^3.2.4", - "vue": "^3.5.26" + "vitest": "^4.0.18", + "vue": "^3.5.29" }, "dependencies": { - "@bentopdf/gs-wasm": "^0.1.0", - "@bentopdf/pymupdf-wasm": "^0.11.12", "@fontsource/cedarville-cursive": "^5.2.7", "@fontsource/dancing-script": "^5.2.8", "@fontsource/dm-sans": "^5.2.8", @@ -73,30 +72,34 @@ "@fontsource/kalam": "^5.2.8", "@fontsource/lato": "^5.2.7", "@fontsource/merriweather": "^5.2.11", - "@kenjiuno/msgreader": "^1.27.1-alpha.1", - "@matbee/libreoffice-converter": "^2.3.1", + "@kenjiuno/msgreader": "^1.28.0", + "@matbee/libreoffice-converter": "^2.5.0", "@neslinesli93/qpdf-wasm": "^0.3.0", "@pdf-lib/fontkit": "^1.1.1", "@phosphor-icons/web": "^2.1.2", - "@tailwindcss/vite": "^4.1.15", + "@retejs/lit-plugin": "^2.0.7", + "@tailwindcss/vite": "^4.2.1", "@types/markdown-it": "^14.1.2", "@types/node-forge": "^1.3.14", "@types/papaparse": "^5.5.2", "archiver": "^7.0.1", "blob-stream": "^0.1.3", - "cropperjs": "^1.6.1", - "embedpdf-snippet": "file:vendor/embedpdf/embedpdf-snippet-1.5.0.tgz", + "bwip-js": "^4.8.0", + "cropperjs": "^1.6.2", + "diff": "^8.0.3", + "embedpdf-snippet": "file:vendor/embedpdf/embedpdf-snippet-2.3.0.tgz", "heic2any": "^0.0.4", "highlight.js": "^11.11.1", "html2canvas": "^1.4.1", - "i18next": "^25.7.2", - "i18next-browser-languagedetector": "^8.2.0", + "i18next": "^25.8.13", + "i18next-browser-languagedetector": "^8.2.1", "i18next-http-backend": "^3.0.2", - "jspdf": "^4.0.0", + "jspdf": "^4.2.0", "jspdf-autotable": "^5.0.2", "jszip": "^3.10.1", - "lucide": "^0.546.0", - "markdown-it": "^14.1.0", + "lit": "^3.3.2", + "lucide": "^0.575.0", + "markdown-it": "^14.1.1", "markdown-it-abbr": "^2.0.0", "markdown-it-anchor": "^9.2.0", "markdown-it-deflist": "^3.0.0", @@ -108,21 +111,28 @@ "markdown-it-sup": "^2.0.0", "markdown-it-task-lists": "^2.1.1", "markdown-it-toc-done-right": "^4.2.0", - "mermaid": "^11.12.2", + "mermaid": "^11.12.3", + "microdiff": "^1.5.0", "node-forge": "^1.3.3", "papaparse": "^5.5.3", "pdf-lib": "^1.17.1", - "pdfjs-dist": "^5.4.296", + "pdfjs-dist": "^5.4.624", "pdfkit": "^0.17.2", - "postal-mime": "^2.7.1", - "sortablejs": "^1.15.6", + "pixelmatch": "^7.1.0", + "postal-mime": "^2.7.3", + "rete": "^2.0.6", + "rete-area-plugin": "^2.1.5", + "rete-connection-plugin": "^2.0.5", + "rete-engine": "^2.1.1", + "rete-render-utils": "^2.0.3", + "sortablejs": "^1.15.7", "tailwindcss": "^4.1.14", - "terser": "^5.44.0", - "tesseract.js": "^6.0.1", + "terser": "^5.46.0", + "tesseract.js": "^7.0.0", "tiff": "^7.1.2", "utif": "^3.1.0", - "vite-plugin-static-copy": "^3.1.4", - "xlsx": "file:vendor/sheetjs/xlsx-0.20.2.tgz", + "vite-plugin-static-copy": "^3.2.0", + "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz", "zgapdfsigner": "^2.7.5" }, "lint-staged": { diff --git a/public/ghostscript-wasm/gs.js b/public/ghostscript-wasm/gs.js deleted file mode 100644 index b2541ea..0000000 --- a/public/ghostscript-wasm/gs.js +++ /dev/null @@ -1,113 +0,0 @@ -async function Module(moduleArg={}){var moduleRtn;var f=moduleArg,aa="object"==typeof window,ba="undefined"!=typeof WorkerGlobalScope,k="object"==typeof process&&process.versions?.node&&"renderer"!=process.type,ca=!aa&&!k&&!ba;if(k){const {createRequire:a}=await import("module");var require=a(import.meta.url)}var da="./this.program",ea=(a,b)=>{throw b;},ia=import.meta.url,ja="",ka,la; -if(k){if("object"!=typeof process||!process.versions?.node||"renderer"==process.type)throw Error("not compiled for this environment (did you build to HTML and try to run it not on the web, or set ENVIRONMENT to something - like node - and run it someplace else - like on the web?)");var ma=process.versions.node,na=ma.split(".").slice(0,3);na=1E4*na[0]+100*na[1]+1*na[2].split("-")[0];if(16E4>na)throw Error("This emscripten-generated code requires node v16.0.0 (detected v"+ma+")");var fs=require("fs"); -ia.startsWith("file:")&&(ja=require("path").dirname(require("url").fileURLToPath(ia))+"/");la=a=>{a=oa(a)?new URL(a):a;a=fs.readFileSync(a);m(Buffer.isBuffer(a));return a};ka=async a=>{a=oa(a)?new URL(a):a;a=fs.readFileSync(a,void 0);m(Buffer.isBuffer(a));return a};1{process.exitCode=a;throw b;}}else if(ca){if("object"==typeof process&&process.versions?.node&&"renderer"!=process.type||"object"==typeof window|| -"undefined"!=typeof WorkerGlobalScope)throw Error("not compiled for this environment (did you build to HTML and try to run it not on the web, or set ENVIRONMENT to something - like node - and run it someplace else - like on the web?)");}else if(aa||ba){try{ja=(new URL(".",ia)).href}catch{}if("object"!=typeof window&&"undefined"==typeof WorkerGlobalScope)throw Error("not compiled for this environment (did you build to HTML and try to run it not on the web, or set ENVIRONMENT to something - like node - and run it someplace else - like on the web?)"); -ba&&(la=a=>{var b=new XMLHttpRequest;b.open("GET",a,!1);b.responseType="arraybuffer";b.send(null);return new Uint8Array(b.response)});ka=async a=>{if(oa(a))return new Promise((c,d)=>{var e=new XMLHttpRequest;e.open("GET",a,!0);e.responseType="arraybuffer";e.onload=()=>{200==e.status||0==e.status&&e.response?c(e.response):d(e.status)};e.onerror=d;e.send(null)});var b=await fetch(a,{credentials:"same-origin"});if(b.ok)return b.arrayBuffer();throw Error(b.status+" : "+b.url);}}else throw Error("environment detection error"); -var p=console.log.bind(console),t=console.error.bind(console);m(!ca,"shell environment detected but not enabled at build time. Add `shell` to `-sENVIRONMENT` to enable.");var pa;"object"!=typeof WebAssembly&&t("no native wasm support detected");var qa=!1,ra;function m(a,b){a||v("Assertion failed"+(b?": "+b:""))}var oa=a=>a.startsWith("file://");function sa(){var a=ta();m(0==(a&3));0==a&&(a+=4);x[a>>2]=34821223;x[a+4>>2]=2310721022;x[0]=1668509029} -function ua(){if(!qa){var a=ta();0==a&&(a+=4);var b=x[a>>2],c=x[a+4>>2];34821223==b&&2310721022==c||v(`Stack overflow! Stack cookie has been overwritten at ${va(a)}, expected hex dwords 0x89BACDFE and 0x2135467, but received ${va(c)} ${va(b)}`);1668509029!=x[0]&&v("Runtime error: The application has corrupted its heap memory area (address zero)!")}}var wa=new Int16Array(1),xa=new Int8Array(wa.buffer);wa[0]=25459; -if(115!==xa[0]||99!==xa[1])throw"Runtime error: expected the system to be little-endian! (Run with -sSUPPORT_BIG_ENDIAN to bypass)";function ya(a){Object.getOwnPropertyDescriptor(f,a)||Object.defineProperty(f,a,{configurable:!0,set(){v(`Attempt to set \`Module.${a}\` after it has already been processed. This can happen, for example, when code is injected via '--post-js' rather than '--pre-js'`)}})} -function y(a){return()=>m(!1,`call to '${a}' via reference taken before Wasm module initialization`)}function Aa(a){return"FS_createPath"===a||"FS_createDataFile"===a||"FS_createPreloadedFile"===a||"FS_preloadFile"===a||"FS_unlink"===a||"addRunDependency"===a||"FS_createLazyFile"===a||"FS_createDevice"===a||"removeRunDependency"===a}function Ba(a,b){"undefined"==typeof globalThis||Object.getOwnPropertyDescriptor(globalThis,a)||Object.defineProperty(globalThis,a,{configurable:!0,get(){b()}})} -function Ca(a,b){Ba(a,()=>{z(`\`${a}\` is not longer defined by emscripten. ${b}`)})}Ca("buffer","Please use HEAP8.buffer or wasmMemory.buffer");Ca("asm","Please use wasmExports instead");function Da(a){Object.getOwnPropertyDescriptor(f,a)||Object.defineProperty(f,a,{configurable:!0,get(){var b=`'${a}' was not exported. add it to EXPORTED_RUNTIME_METHODS (see the Emscripten FAQ)`;Aa(a)&&(b+=". Alternatively, forcing filesystem support (-sFORCE_FILESYSTEM) can export this for you");v(b)}})} -var Ea,Fa,Ga,A,Ha,Ia,B,x,C,Ja=!1;function Ka(){var a=Ga.buffer;A=new Int8Array(a);Ia=new Int16Array(a);Ha=new Uint8Array(a);new Uint16Array(a);B=new Int32Array(a);x=new Uint32Array(a);new Float32Array(a);new Float64Array(a);C=new BigInt64Array(a);new BigUint64Array(a)}m("undefined"!=typeof Int32Array&&"undefined"!==typeof Float64Array&&void 0!=Int32Array.prototype.subarray&&void 0!=Int32Array.prototype.set,"JS engine does not provide full typed array support");var E=0,La=null,Ma={},I=null; -function Na(a){E++;f.monitorRunDependencies?.(E);m(a,"addRunDependency requires an ID");m(!Ma[a]);Ma[a]=1;null===I&&"undefined"!=typeof setInterval&&(I=setInterval(()=>{if(qa)clearInterval(I),I=null;else{var b=!1,c;for(c in Ma)b||(b=!0,t("still waiting on run dependencies:")),t(`dependency: ${c}`);b&&t("(end of list)")}},1E4),I.unref?.())} -function Oa(a){E--;f.monitorRunDependencies?.(E);m(a,"removeRunDependency requires an ID");m(Ma[a]);delete Ma[a];0==E&&(null!==I&&(clearInterval(I),I=null),La&&(a=La,La=null,a()))}function v(a){f.onAbort?.(a);a="Aborted("+a+")";t(a);qa=!0;a=new WebAssembly.RuntimeError(a);Fa?.(a);throw a;} -function K(a,b){return(...c)=>{m(Ja,`native function \`${a}\` called before runtime initialization`);var d=L[a];m(d,`exported native function \`${a}\` not found`);m(c.length<=b,`native function \`${a}\` called with ${c.length} args but expects ${b}`);return d(...c)}}var Pa;async function Qa(a){if(!pa)try{var b=await ka(a);return new Uint8Array(b)}catch{}if(a==Pa&&pa)a=new Uint8Array(pa);else if(la)a=la(a);else throw"both async and sync fetching of the wasm failed";return a} -async function Ra(a,b){try{var c=await Qa(a);return await WebAssembly.instantiate(c,b)}catch(d){t(`failed to asynchronously prepare wasm: ${d}`),oa(Pa)&&t(`warning: Loading from a file URI (${Pa}) is not supported in most browsers. See https://emscripten.org/docs/getting_started/FAQ.html#how-do-i-run-a-local-webserver-for-testing-why-does-my-program-stall-in-downloading-or-preparing`),v(d)}} -async function Sa(a){var b=Pa;if(!pa&&!oa(b)&&!k)try{var c=fetch(b,{credentials:"same-origin"});return await WebAssembly.instantiateStreaming(c,a)}catch(d){t(`wasm streaming compile failed: ${d}`),t("falling back to ArrayBuffer instantiation")}return Ra(b,a)}class Ta{name="ExitStatus";constructor(a){this.message=`Program terminated with exit(${a})`;this.status=a}} -var Ua=a=>{for(;0{var a=f.preRun.shift();Wa.push(a)},Ya=!0,va=a=>{m("number"===typeof a);return"0x"+(a>>>0).toString(16).padStart(8,"0")},z=a=>{z.$||(z.$={});z.$[a]||(z.$[a]=1,k&&(a="warning: "+a),t(a))},Za="undefined"!=typeof TextDecoder?new TextDecoder:void 0,$a=(a,b=0)=>{var c=b;for(var d=c+void 0;a[c]&&!(c>=d);)++c;if(16e?d+=String.fromCharCode(e):(e-=65536,d+=String.fromCharCode(55296|e>>10,56320|e&1023))}}else d+=String.fromCharCode(e)}return d},M=a=>{m("number"==typeof a,`UTF8ToString expects a number (got ${typeof a})`);return a?$a(Ha,a):""},ab=(a,b)=>{for(var c=0,d=a.length- -1;0<=d;d--){var e=a[d];"."===e?a.splice(d,1):".."===e?(a.splice(d,1),c++):c&&(a.splice(d,1),c--)}if(b)for(;c;c--)a.unshift("..");return a},bb=a=>{var b="/"===a.charAt(0),c="/"===a.slice(-1);(a=ab(a.split("/").filter(d=>!!d),!b).join("/"))||b||(a=".");a&&c&&(a+="/");return(b?"/":"")+a},cb=a=>{var b=/^(\/?|)([\s\S]*?)((?:\.{1,2}|[^\/]+?|)(\.[^.\/]*|))(?:[\/]*)$/.exec(a).slice(1);a=b[0];b=b[1];if(!a&&!b)return".";b&&=b.slice(0,-1);return a+b},N=a=>a&&a.match(/([^\/]+|\/)\/*$/)[1],db=(a,b)=>bb(a+"/"+ -b),eb=()=>{if(k){var a=require("crypto");return b=>a.randomFillSync(b)}return b=>crypto.getRandomValues(b)},fb=a=>{(fb=eb())(a)},gb=(...a)=>{for(var b="",c=!1,d=a.length-1;-1<=d&&!c;d--){c=0<=d?a[d]:O.cwd();if("string"!=typeof c)throw new TypeError("Arguments to path.resolve must be strings");if(!c)return"";b=c+"/"+b;c="/"===c.charAt(0)}b=ab(b.split("/").filter(e=>!!e),!c).join("/");return(c?"/":"")+b||"."},hb=(a,b)=>{function c(h){for(var l=0;lr?[]:h.slice(l,r-l+1)}a=gb(a).slice(1);b=gb(b).slice(1);a=c(a.split("/"));b=c(b.split("/"));for(var d=Math.min(a.length,b.length),e=d,g=0;g{for(var b=0,c=0;c=d?b++:2047>=d?b+=2:55296<=d&&57343>=d?(b+=4,++c):b+=3}return b},kb=(a,b,c,d)=>{m("string"===typeof a,`stringToUTF8Array expects a string (got ${typeof a})`); -if(!(0=h){if(c>=d)break;b[c++]=h}else if(2047>=h){if(c+1>=d)break;b[c++]=192|h>>6;b[c++]=128|h&63}else if(65535>=h){if(c+2>=d)break;b[c++]=224|h>>12;b[c++]=128|h>>6&63;b[c++]=128|h&63}else{if(c+3>=d)break;1114111>18;b[c++]= -128|h>>12&63;b[c++]=128|h>>6&63;b[c++]=128|h&63;g++}}b[c]=0;return c-e},lb=a=>{var b=Array(jb(a)+1);a=kb(a,b,0,b.length);b.length=a;return b},mb=[];function nb(a,b){mb[a]={input:[],output:[],H:b};ob(a,pb)} -var pb={open(a){var b=mb[a.node.rdev];if(!b)throw new O.g(43);a.tty=b;a.seekable=!1},close(a){a.tty.H.fsync(a.tty)},fsync(a){a.tty.H.fsync(a.tty)},read(a,b,c,d){if(!a.tty||!a.tty.H.ha)throw new O.g(60);for(var e=0,g=0;g{v("internal error: mmapAlloc called but `emscripten_builtin_memalign` native symbol not exported")}, -P={D:null,m(){return P.createNode(null,"/",16895,0)},createNode(a,b,c,d){if(24576===(c&61440)||O.isFIFO(c))throw new O.g(63);P.D||(P.D={dir:{node:{v:P.h.v,B:P.h.B,lookup:P.h.lookup,G:P.h.G,rename:P.h.rename,unlink:P.h.unlink,rmdir:P.h.rmdir,readdir:P.h.readdir,symlink:P.h.symlink},stream:{s:P.i.s}},file:{node:{v:P.h.v,B:P.h.B},stream:{s:P.i.s,read:P.i.read,write:P.i.write,M:P.i.M,P:P.i.P}},link:{node:{v:P.h.v,B:P.h.B,readlink:P.h.readlink},stream:{}},ba:{node:{v:P.h.v,B:P.h.B},stream:O.ta}});c=O.createNode(a, -b,c,d);Q(c.mode)?(c.h=P.D.dir.node,c.i=P.D.dir.stream,c.j={}):O.isFile(c.mode)?(c.h=P.D.file.node,c.i=P.D.file.stream,c.o=0,c.j=null):40960===(c.mode&61440)?(c.h=P.D.link.node,c.i=P.D.link.stream):8192===(c.mode&61440)&&(c.h=P.D.ba.node,c.i=P.D.ba.stream);c.atime=c.mtime=c.ctime=Date.now();a&&(a.j[b]=c,a.atime=a.mtime=a.ctime=c.atime);return c},fb(a){return a.j?a.j.subarray?a.j.subarray(0,a.o):new Uint8Array(a.j):new Uint8Array(0)},h:{v(a){var b={};b.dev=8192===(a.mode&61440)?a.id:1;b.ino=a.id;b.mode= -a.mode;b.nlink=1;b.uid=0;b.gid=0;b.rdev=a.rdev;Q(a.mode)?b.size=4096:O.isFile(a.mode)?b.size=a.o:40960===(a.mode&61440)?b.size=a.link.length:b.size=0;b.atime=new Date(a.atime);b.mtime=new Date(a.mtime);b.ctime=new Date(a.ctime);b.blksize=4096;b.blocks=Math.ceil(b.size/b.blksize);return b},B(a,b){for(var c of["mode","atime","mtime","ctime"])null!=b[c]&&(a[c]=b[c]);void 0!==b.size&&(b=b.size,a.o!=b&&(0==b?(a.j=null,a.o=0):(c=a.j,a.j=new Uint8Array(b),c&&a.j.set(c.subarray(0,Math.min(b,a.o))),a.o=b)))}, -lookup(){throw new O.g(44);},G(a,b,c,d){return P.createNode(a,b,c,d)},rename(a,b,c){try{var d=R(b,c)}catch(g){}if(d){if(Q(a.mode))for(var e in d.j)throw new O.g(55);tb(d)}delete a.parent.j[a.name];b.j[c]=a;a.name=c;b.ctime=b.mtime=a.parent.ctime=a.parent.mtime=Date.now()},unlink(a,b){delete a.j[b];a.ctime=a.mtime=Date.now()},rmdir(a,b){var c=R(a,b),d;for(d in c.j)throw new O.g(55);delete a.j[b];a.ctime=a.mtime=Date.now()},readdir(a){return[".","..",...Object.keys(a.j)]},symlink(a,b,c){a=P.createNode(a, -b,41471,0);a.link=c;return a},readlink(a){if(40960!==(a.mode&61440))throw new O.g(28);return a.link}},i:{read(a,b,c,d,e){var g=a.node.j;if(e>=a.node.o)return 0;a=Math.min(a.node.o-e,d);m(0<=a);if(8=g||(g=Math.max(g,h*(1048576>h?2:1.125)>>>0),0!=h&&(g=Math.max(g,256)),h=a.j,a.j=new Uint8Array(g),0b)throw new O.g(28); -return b},M(a,b,c,d,e){if(!O.isFile(a.node.mode))throw new O.g(43);a=a.node.j;if(e&2||!a||a.buffer!==A.buffer){d=!0;e=sb();if(!e)throw new O.g(48);if(a){if(0{var c=0;a&&(c|=365);b&&(c|=146);return c},vb={EPERM:63,ENOENT:44,ESRCH:71,EINTR:27,EIO:29,ENXIO:60,E2BIG:1,ENOEXEC:45,EBADF:8,ECHILD:12,EAGAIN:6, -EWOULDBLOCK:6,ENOMEM:48,EACCES:2,EFAULT:21,ENOTBLK:105,EBUSY:10,EEXIST:20,EXDEV:75,ENODEV:43,ENOTDIR:54,EISDIR:31,EINVAL:28,ENFILE:41,EMFILE:33,ENOTTY:59,ETXTBSY:74,EFBIG:22,ENOSPC:51,ESPIPE:70,EROFS:69,EMLINK:34,EPIPE:64,EDOM:18,ERANGE:68,ENOMSG:49,EIDRM:24,ECHRNG:106,EL2NSYNC:156,EL3HLT:107,EL3RST:108,ELNRNG:109,EUNATCH:110,ENOCSI:111,EL2HLT:112,EDEADLK:16,ENOLCK:46,EBADE:113,EBADR:114,EXFULL:115,ENOANO:104,EBADRQC:103,EBADSLT:102,EDEADLOCK:16,EBFONT:101,ENOSTR:100,ENODATA:116,ETIME:117,ENOSR:118, -ENONET:119,ENOPKG:120,EREMOTE:121,ENOLINK:47,EADV:122,ESRMNT:123,ECOMM:124,EPROTO:65,EMULTIHOP:36,EDOTDOT:125,EBADMSG:9,ENOTUNIQ:126,EBADFD:127,EREMCHG:128,ELIBACC:129,ELIBBAD:130,ELIBSCN:131,ELIBMAX:132,ELIBEXEC:133,ENOSYS:52,ENOTEMPTY:55,ENAMETOOLONG:37,ELOOP:32,EOPNOTSUPP:138,EPFNOSUPPORT:139,ECONNRESET:15,ENOBUFS:42,EAFNOSUPPORT:5,EPROTOTYPE:67,ENOTSOCK:57,ENOPROTOOPT:50,ESHUTDOWN:140,ECONNREFUSED:14,EADDRINUSE:3,ECONNABORTED:13,ENETUNREACH:40,ENETDOWN:38,ETIMEDOUT:73,EHOSTDOWN:142,EHOSTUNREACH:23, -EINPROGRESS:26,EALREADY:7,EDESTADDRREQ:17,EMSGSIZE:35,EPROTONOSUPPORT:66,ESOCKTNOSUPPORT:137,EADDRNOTAVAIL:4,ENETRESET:39,EISCONN:30,ENOTCONN:53,ETOOMANYREFS:141,EUSERS:136,EDQUOT:19,ESTALE:72,ENOTSUP:138,ENOMEDIUM:148,EILSEQ:25,EOVERFLOW:61,ECANCELED:11,ENOTRECOVERABLE:56,EOWNERDEAD:62,ESTRPIPE:135},wb=async a=>{var b=await ka(a);m(b,`Loading data file "${a}" failed (no arrayBuffer).`);return new Uint8Array(b)},xb=[],zb=async(a,b)=>{"undefined"!=typeof Browser&&yb();for(var c of xb)if(c.canHandle(b))return m("AsyncFunction"=== -c.handle.constructor.name,"Filesystem plugin handlers must be async functions (See #24914)"),c.handle(a,b);return a},Bb=async(a,b,c,d,e,g,h,l)=>{var r=b?gb(bb(a+"/"+b)):a,q;a:for(var u=q=`cp ${r}`;;){if(!Ma[q])break a;q=u+Math.random()}Na(q);try{if(u=c,"string"==typeof c&&(u=await wb(c)),u=await zb(u,r),l?.(),!g){c=u;g=b;a&&(a="string"==typeof a?a:Ab(a),g=b?bb(a+"/"+b):a);var n=ub(d,e),w=O.create(g,n);if(c){if("string"==typeof c){var F=Array(c.length);b=0;for(var G=c.length;bc;c++){a=a.split("/").filter(l=>!!l);for(var d=O.root,e="/",g=0;g>>0)%O.C.length}function Db(a){var b=Cb(a.parent.id,a.name);a.L=O.C[b];O.C[b]=a} -function Eb(a){var b=["r","w","rw"][a&3];a&512&&(b+="w");return b}function S(a,b){if(O.ia)return 0;if(!b.includes("r")||a.mode&292){if(b.includes("w")&&!(a.mode&146)||b.includes("x")&&!(a.mode&73))return 2}else return 2;return 0}function Fb(a,b){if(!Q(a.mode))return 54;try{return R(a,b),20}catch(c){}return S(a,"wx")} -function Gb(a,b,c){try{var d=R(a,b)}catch(e){return e.l}if(a=S(a,"wx"))return a;if(c){if(!Q(d.mode))return 54;if(O.N(d)||Ab(d)===O.cwd())return 10}else if(Q(d.mode))return 31;return 0}function Hb(a,b){if(!a)throw new O.g(b);return a}function U(a){a=O.ga(a);if(!a)throw new O.g(8);return a}function Ib(a,b=-1){m(-1<=b);a=Object.assign(new O.ra,a);if(-1==b)a:{for(b=0;b<=O.aa;b++)if(!O.streams[b])break a;throw new O.g(33);}a.fd=b;return O.streams[b]=a} -function Jb(a,b=-1){a=Ib(a,b);a.i?.Za?.(a);return a}function Kb(a,b,c){var d=a?.i.B;a=d?a:b;d??=b.h.B;Hb(d,63);d(a,c)}function Lb(a){var b=[];for(a=[a];a.length;){var c=a.pop();b.push(c);a.push(...c.O)}return b}function Mb(a){var b={Na:4096,bb:4096,blocks:1E6,Ma:5E5,La:5E5,files:O.Y,$a:O.Y-1,cb:42,flags:2,kb:255};a.h.oa&&Object.assign(b,a.h.oa(a.m.Ea.root));return b}function Nb(a,b,c){"undefined"==typeof c&&(c=b,b=438);return O.G(a,b|8192,c)} -function Ob(a,b,c,d){Kb(a,b,{mode:c&4095|b.mode&-4096,ctime:Date.now(),fa:d})}function Pb(a,b,c){if(Q(b.mode))throw new O.g(31);if(!O.isFile(b.mode))throw new O.g(28);var d=S(b,"w");if(d)throw new O.g(d);Kb(a,b,{size:c,timestamp:Date.now()})} -function Qb(a,b){try{var c=T(a,{u:!b});a=c.path}catch(e){}var d={N:!1,exists:!1,error:0,name:null,path:null,object:null,Fa:!1,Ha:null,Ga:null};try{c=T(a,{parent:!0}),d.Fa=!0,d.Ha=c.path,d.Ga=c.node,d.name=N(a),c=T(a,{u:!b}),d.exists=!0,d.path=c.path,d.object=c.node,d.name=c.node.name,d.N="/"===c.path}catch(e){d.error=e.l}return d}function Rb(a,b,c,d){a="string"==typeof a?a:Ab(a);b=bb(a+"/"+b);return O.create(b,ub(c,d))} -function Sb(a){if(!(a.Aa||a.Ba||a.link||a.j)){if("undefined"!=typeof XMLHttpRequest)throw Error("Lazy loading should have been performed (contents set) in createLazyFile, but it was not. Lazy loading only works in web workers. Use --embed-file or --preload-file in emcc on the main thread.");try{a.j=la(a.url),a.o=a.j.length}catch(b){throw new O.g(29);}}} -var O={root:null,O:[],ea:{},streams:[],Y:1,C:null,da:"/",T:!1,ia:!0,va:null,R:0,na:{},g:class extends Error{name="ErrnoError";constructor(a){super(Ja?M(Tb(a)):"");this.l=a;for(var b in vb)if(vb[b]===a){this.code=b;break}}},ra:class{F={};node=null;get object(){return this.node}set object(a){this.node=a}get flags(){return this.F.flags}set flags(a){this.F.flags=a}get position(){return this.F.position}set position(a){this.F.position=a}},qa:class{h={};i={};A=null;constructor(a,b,c,d){a||=this;this.parent= -a;this.m=a.m;this.id=O.Y++;this.name=b;this.mode=c;this.rdev=d;this.atime=this.mtime=this.ctime=Date.now()}get read(){return 365===(this.mode&365)}set read(a){a?this.mode|=365:this.mode&=-366}get write(){return 146===(this.mode&146)}set write(a){a?this.mode|=146:this.mode&=-147}get Ba(){return Q(this.mode)}get Aa(){return 8192===(this.mode&61440)}},createNode(a,b,c,d){m("object"==typeof a);a=new O.qa(a,b,c,d);Db(a);return a},N(a){return a===a.parent},isFile(a){return 32768===(a&61440)},isFIFO(a){return 4096=== -(a&61440)},isSocket(a){return 49152===(a&49152)},aa:4096,ga:a=>O.streams[a],ta:{open(a){a.i=O.wa(a.node.rdev).i;a.i.open?.(a)},s(){throw new O.g(70);}},X:a=>a>>8,hb:a=>a&255,K:(a,b)=>a<<8|b,wa:a=>O.ea[a],pa(a,b){function c(h){m(0=e.length&&c(null)}"function"==typeof a&&(b=a,a=!1);O.R++;1 -{if(!h.type.pa)return d(null);h.type.pa(h,a,d)})},m(a,b,c){if("string"==typeof a)throw a;var d="/"===c,e=!c;if(d&&O.root)throw new O.g(10);if(!d&&!e){var g=T(c,{S:!1});c=g.path;g=g.node;if(g.A)throw new O.g(10);if(!Q(g.mode))throw new O.g(54);}b={type:a,Ea:b,ma:c,O:[]};a=a.m(b);a.m=b;b.root=a;d?O.root=a:g&&(g.A=b,g.m&&g.m.O.push(b));return a},qb(a){a=T(a,{S:!1});if(!a.node.A)throw new O.g(28);a=a.node;var b=a.A,c=Lb(b);Object.keys(O.C).forEach(d=>{for(d=O.C[d];d;){var e=d.L;c.includes(d.m)&&tb(d); -d=e}});a.A=null;b=a.m.O.indexOf(b);m(-1!==b);a.m.O.splice(b,1)},lookup(a,b){return a.h.lookup(a,b)},G(a,b,c){var d=T(a,{parent:!0}).node;a=N(a);if(!a)throw new O.g(28);if("."===a||".."===a)throw new O.g(20);var e=Fb(d,a);if(e)throw new O.g(e);if(!d.h.G)throw new O.g(63);return d.h.G(d,a,b,c)},oa(a){return Mb(T(a,{u:!0}).node)},ob(a){return Mb(a.node)},create(a,b=438){return O.G(a,b&4095|32768,0)},mkdir(a,b=511){return O.G(a,b&1023|16384,0)},ib(a,b){var c=a.split("/"),d="",e;for(e of c)if(e){if(d|| -"/"===a.charAt(0))d+="/";d+=e;try{O.mkdir(d,b)}catch(g){if(20!=g.l)throw g;}}},symlink(a,b){if(!gb(a))throw new O.g(44);var c=T(b,{parent:!0}).node;if(!c)throw new O.g(44);b=N(b);var d=Fb(c,b);if(d)throw new O.g(d);if(!c.h.symlink)throw new O.g(63);return c.h.symlink(c,b,a)},rename(a,b){var c=cb(a),d=cb(b),e=N(a),g=N(b);var h=T(a,{parent:!0});var l=h.node;h=T(b,{parent:!0});h=h.node;if(!l||!h)throw new O.g(44);if(l.m!==h.m)throw new O.g(75);var r=R(l,e);a=hb(a,d);if("."!==a.charAt(0))throw new O.g(28); -a=hb(b,c);if("."!==a.charAt(0))throw new O.g(55);try{var q=R(h,g)}catch(u){}if(r!==q){b=Q(r.mode);if(e=Gb(l,e,b))throw new O.g(e);if(e=q?Gb(h,g,b):Fb(h,g))throw new O.g(e);if(!l.h.rename)throw new O.g(63);if(r.A||q&&q.A)throw new O.g(10);if(h!==l&&(e=S(l,"w")))throw new O.g(e);tb(r);try{l.h.rename(r,h,g),r.parent=h}catch(u){throw u;}finally{Db(r)}}},rmdir(a){var b=T(a,{parent:!0}).node;a=N(a);var c=R(b,a),d=Gb(b,a,!0);if(d)throw new O.g(d);if(!b.h.rmdir)throw new O.g(63);if(c.A)throw new O.g(10); -b.h.rmdir(b,a);tb(c)},readdir(a){a=T(a,{u:!0}).node;return Hb(a.h.readdir,54)(a)},unlink(a){var b=T(a,{parent:!0}).node;if(!b)throw new O.g(44);a=N(a);var c=R(b,a),d=Gb(b,a,!1);if(d)throw new O.g(d);if(!b.h.unlink)throw new O.g(63);if(c.A)throw new O.g(10);b.h.unlink(b,a);tb(c)},readlink(a){a=T(a).node;if(!a)throw new O.g(44);if(!a.h.readlink)throw new O.g(28);return a.h.readlink(a)},stat(a,b){a=T(a,{u:!b}).node;return Hb(a.h.v,63)(a)},fstat(a){var b=U(a);a=b.node;var c=b.i.v;b=c?b:a;c??=a.h.v;Hb(c, -63);return c(b)},lstat(a){return O.stat(a,!0)},chmod(a,b,c){a="string"==typeof a?T(a,{u:!c}).node:a;Ob(null,a,b,c)},lchmod(a,b){O.chmod(a,b,!0)},fchmod(a,b){a=U(a);Ob(a,a.node,b,!1)},chown(a,b,c,d){a="string"==typeof a?T(a,{u:!d}).node:a;Kb(null,a,{timestamp:Date.now(),fa:d})},lchown(a,b,c){O.chown(a,b,c,!0)},fchown(a){a=U(a);Kb(a,a.node,{timestamp:Date.now(),fa:!1})},truncate(a,b){if(0>b)throw new O.g(28);a="string"==typeof a?T(a,{u:!0}).node:a;Pb(null,a,b)},eb(a,b){a=U(a);if(0>b||0===(a.flags&2097155))throw new O.g(28); -Pb(a,a.node,b)},rb(a,b,c){a=T(a,{u:!0}).node;Hb(a.h.B,63)(a,{atime:b,mtime:c})},open(a,b,c=438){if(""===a)throw new O.g(44);if("string"==typeof b){var d={r:0,"r+":2,w:577,"w+":578,a:1089,"a+":1090}[b];if("undefined"==typeof d)throw Error(`Unknown file open mode: ${b}`);b=d}c=b&64?c&4095|32768:0;if("object"==typeof a)d=a;else{var e=a.endsWith("/");a=T(a,{u:!(b&131072),Ca:!0});d=a.node;a=a.path}var g=!1;if(b&64)if(d){if(b&128)throw new O.g(20);}else{if(e)throw new O.g(31);d=O.G(a,c|511,0);g=!0}if(!d)throw new O.g(44); -8192===(d.mode&61440)&&(b&=-513);if(b&65536&&!Q(d.mode))throw new O.g(54);if(!g&&(e=d?40960===(d.mode&61440)?32:Q(d.mode)&&("r"!==Eb(b)||b&576)?31:S(d,Eb(b)):44))throw new O.g(e);b&512&&!g&&O.truncate(d,0);b&=-131713;e=Ib({node:d,path:Ab(d),flags:b,seekable:!0,position:0,i:d.i,Ja:[],error:!1});e.i.open&&e.i.open(e);g&&O.chmod(d,c&511);!f.logReadFiles||b&1||a in O.na||(O.na[a]=1);return e},close(a){if(null===a.fd)throw new O.g(8);a.J&&(a.J=null);try{a.i.close&&a.i.close(a)}catch(b){throw b;}finally{O.streams[a.fd]= -null}a.fd=null},s(a,b,c){if(null===a.fd)throw new O.g(8);if(!a.seekable||!a.i.s)throw new O.g(70);if(0!=c&&1!=c&&2!=c)throw new O.g(28);a.position=a.i.s(a,b,c);a.Ja=[];return a.position},read(a,b,c,d,e){m(0<=c);if(0>d||0>e)throw new O.g(28);if(null===a.fd)throw new O.g(8);if(1===(a.flags&2097155))throw new O.g(8);if(Q(a.node.mode))throw new O.g(31);if(!a.i.read)throw new O.g(28);var g="undefined"!=typeof e;if(!g)e=a.position;else if(!a.seekable)throw new O.g(70);b=a.i.read(a,b,c,d,e);g||(a.position+= -b);return b},write(a,b,c,d,e,g){m(0<=c);if(0>d||0>e)throw new O.g(28);if(null===a.fd)throw new O.g(8);if(0===(a.flags&2097155))throw new O.g(8);if(Q(a.node.mode))throw new O.g(31);if(!a.i.write)throw new O.g(28);a.seekable&&a.flags&1024&&O.s(a,0,2);var h="undefined"!=typeof e;if(!h)e=a.position;else if(!a.seekable)throw new O.g(70);b=a.i.write(a,b,c,d,e,g);h||(a.position+=b);return b},M(a,b,c,d,e){if(0!==(d&2)&&0===(e&2)&&2!==(a.flags&2097155))throw new O.g(2);if(1===(a.flags&2097155))throw new O.g(2); -if(!a.i.M)throw new O.g(43);if(!b)throw new O.g(28);return a.i.M(a,b,c,d,e)},P(a,b,c,d,e){m(0<=c);return a.i.P?a.i.P(a,b,c,d,e):0},W(a,b,c){if(!a.i.W)throw new O.g(59);return a.i.W(a,b,c)},readFile(a,b={}){b.flags=b.flags||0;b.encoding=b.encoding||"binary";if("utf8"!==b.encoding&&"binary"!==b.encoding)throw Error(`Invalid encoding type "${b.encoding}"`);var c=O.open(a,b.flags);a=O.stat(a).size;var d=new Uint8Array(a);O.read(c,d,0,a,0);"utf8"===b.encoding&&(d=$a(d));O.close(c);return d},writeFile(a, -b,c={}){c.flags=c.flags||577;a=O.open(a,c.flags,c.mode);"string"==typeof b&&(b=new Uint8Array(lb(b)));if(ArrayBuffer.isView(b))O.write(a,b,0,b.byteLength,void 0,c.Ta);else throw Error("Unsupported data type");O.close(a)},cwd:()=>O.da,chdir(a){a=T(a,{u:!0});if(null===a.node)throw new O.g(44);if(!Q(a.node.mode))throw new O.g(54);var b=S(a.node,"x");if(b)throw new O.g(b);O.da=a.path},mb(){O.T=!1;Ub(0);for(var a of O.streams)a&&O.close(a)},ab(a,b){a=Qb(a,b);return a.exists?a.object:null},Xa(a,b){a="string"== -typeof a?a:Ab(a);for(b=b.split("/").reverse();b.length;){var c=b.pop();if(c){var d=bb(a+"/"+c);try{O.mkdir(d)}catch(e){if(20!=e.l)throw e;}a=d}}return d},I(a,b,c,d){a=db("string"==typeof a?a:Ab(a),b);b=ub(!!c,!!d);var e;(e=O.I).X??(e.X=64);e=O.K(O.I.X++,0);ob(e,{open(g){g.seekable=!1},close(){d?.buffer?.length&&d(10)},read(g,h,l,r){for(var q=0,u=0;u=n.length)return 0;G=Math.min(n.length-D,G);m(0<=G);if(n.slice)for(var H=0;Hthis.length-1||0>n)){var w=n%this.chunkSize;return this.U(n/this.chunkSize|0)[w]}}Da(n){this.U= -n}la(){var n=new XMLHttpRequest;n.open("HEAD",c,!1);n.send(null);if(!(200<=n.status&&300>n.status||304===n.status))throw Error("Couldn't load "+c+". Status: "+n.status);var w=Number(n.getResponseHeader("Content-length")),F,G=(F=n.getResponseHeader("Accept-Ranges"))&&"bytes"===F;n=(F=n.getResponseHeader("Content-Encoding"))&&"gzip"===F;var D=1048576;G||(D=w);var H=this;H.Da(fa=>{var za=fa*D,ha=(fa+1)*D-1;ha=Math.min(ha,w-1);if("undefined"==typeof H.F[fa]){var Bc=H.F;if(za>ha)throw Error("invalid range ("+ -za+", "+ha+") or no bytes requested!");if(ha>w-1)throw Error("only "+w+" bytes available! programmer error!");var J=new XMLHttpRequest;J.open("GET",c,!1);w!==D&&J.setRequestHeader("Range","bytes="+za+"-"+ha);J.responseType="arraybuffer";J.overrideMimeType&&J.overrideMimeType("text/plain; charset=x-user-defined");J.send(null);if(!(200<=J.status&&300>J.status||304===J.status))throw Error("Couldn't load "+c+". Status: "+J.status);za=void 0!==J.response?new Uint8Array(J.response||[]):lb(J.responseText|| -"");Bc[fa]=za}if("undefined"==typeof H.F[fa])throw Error("doXHR failed!");return H.F[fa]});if(n||!w)D=w=1,D=w=this.U(0).length,p("LazyFiles on gzip forces download of the whole file when length is accessed");this.ka=w;this.ja=D;this.V=!0}get length(){this.V||this.la();return this.ka}get chunkSize(){this.V||this.la();return this.ja}}if("undefined"!=typeof XMLHttpRequest){if(!ba)throw"Cannot do synchronous binary XHRs outside webworkers in modern browsers. Use --embed-file or --preload-file in emcc"; -var l=new h;var r=void 0}else r=c,l=void 0;var q=Rb(a,b,d,e);l?q.j=l:r&&(q.j=null,q.url=r);Object.defineProperties(q,{o:{get:function(){return this.j.length}}});var u={};Object.keys(q.i).forEach(n=>{var w=q.i[n];u[n]=(...F)=>{Sb(q);return w(...F)}});u.read=(n,w,F,G,D)=>{Sb(q);return g(n,w,F,G,D)};u.M=(n,w,F)=>{Sb(q);var G=sb();if(!G)throw new O.g(48);g(n,A,G,w,F);return{Ia:G,sa:!0}};q.i=u;return q},Ka(){v("FS.absolutePath has been removed; use PATH_FS.resolve instead")},Ua(){v("FS.createFolder has been removed; use FS.mkdir instead")}, -Wa(){v("FS.createLink has been removed; use FS.symlink instead")},gb(){v("FS.joinPath has been removed; use PATH.join instead")},jb(){v("FS.mmapAlloc has been replaced by the top level function mmapAlloc")},nb(){v("FS.standardizePath has been removed; use PATH.normalize instead")}};function Vb(a,b,c){if("/"===b.charAt(0))return b;a=-100===a?O.cwd():U(a).path;if(0==b.length){if(!c)throw new O.g(44);return a}return a+"/"+b} -function Wb(a,b){x[a>>2]=b.dev;x[a+4>>2]=b.mode;x[a+8>>2]=b.nlink;x[a+12>>2]=b.uid;x[a+16>>2]=b.gid;x[a+20>>2]=b.rdev;C[a+24>>3]=BigInt(b.size);B[a+32>>2]=4096;B[a+36>>2]=b.blocks;var c=b.atime.getTime(),d=b.mtime.getTime(),e=b.ctime.getTime();C[a+40>>3]=BigInt(Math.floor(c/1E3));x[a+48>>2]=c%1E3*1E6;C[a+56>>3]=BigInt(Math.floor(d/1E3));x[a+64>>2]=d%1E3*1E6;C[a+72>>3]=BigInt(Math.floor(e/1E3));x[a+80>>2]=e%1E3*1E6;C[a+88>>3]=BigInt(b.ino);return 0} -var Xb=void 0,V=()=>{m(void 0!=Xb);var a=B[+Xb>>2];Xb+=4;return a},W=(a,b,c)=>{m("number"==typeof c,"stringToUTF8(str, outPtr, maxBytesToWrite) is missing the third parameter that specifies the length of the output buffer!");return kb(a,Ha,b,c)},Yb=0,Zb=a=>0===a%4&&(0!==a%100||0===a%400),$b=[0,31,60,91,121,152,182,213,244,274,305,335],ac=[0,31,59,90,120,151,181,212,243,273,304,334],bc={},cc=a=>{if(a instanceof Ta||"unwind"==a)return ra;ua();a instanceof WebAssembly.RuntimeError&&0>=X()&&t("Stack overflow detected. You can try increasing -sSTACK_SIZE (currently set to 65536)"); -ea(1,a)},dc=a=>{ra=a;Ya||0{ra=a;ec();(Ya||0{if(qa)t("user callback triggered after runtime exited or application aborted. Ignoring."); -else try{if(a(),!(Ya||0{if(!ic){var a={USER:"web_user",LOGNAME:"web_user",PATH:"/",PWD:"/",HOME:"/home/web_user",LANG:("object"==typeof navigator&&navigator.language||"C").replace("-","_")+".UTF-8",_:da||"./this.program"},b;for(b in hc)void 0===hc[b]?delete a[b]:a[b]=hc[b];var c=[];for(b in a)c.push(`${b}=${a[b]}`);ic=c}return ic},ic,kc=(a,b,c,d)=>{for(var e=0,g=0;g>2],l=x[b+4>>2];b+=8;h=O.read(a,A,h,l,d);if(0>h)return-1; -e+=h;if(h{for(var e=0,g=0;g>2],l=x[b+4>>2];b+=8;h=O.write(a,A,h,l,d);if(0>h)return-1;e+=h;if(h{Bb(a,b,c,d,e,l,r,q).then(g).catch(h)};O.lb=Bb;O.C=Array(4096);O.m(P,{},"/");O.mkdir("/tmp");O.mkdir("/home");O.mkdir("/home/web_user"); -(function(){O.mkdir("/dev");ob(O.K(1,3),{read:()=>0,write:(d,e,g,h)=>h,s:()=>0});Nb("/dev/null",O.K(1,3));nb(O.K(5,0),qb);nb(O.K(6,0),rb);Nb("/dev/tty",O.K(5,0));Nb("/dev/tty1",O.K(6,0));var a=new Uint8Array(1024),b=0,c=()=>{0===b&&(fb(a),b=a.byteLength);return a[--b]};O.I("/dev","random",c);O.I("/dev","urandom",c);O.mkdir("/dev/shm");O.mkdir("/dev/shm/tmp")})(); -(function(){O.mkdir("/proc");var a=O.mkdir("/proc/self");O.mkdir("/proc/self/fd");O.m({m(){var b=O.createNode(a,"fd",16895,73);b.i={s:P.i.s};b.h={lookup(c,d){c=+d;var e=U(c);c={parent:null,m:{ma:"fake"},h:{readlink:()=>e.path},id:c+1};return c.parent=c},readdir(){return Array.from(O.streams.entries()).filter(([,c])=>c).map(([c])=>c.toString())}};return b}},{},"/proc/self/fd")})();O.va={MEMFS:P};f.noExitRuntime&&(Ya=f.noExitRuntime);f.preloadPlugins&&(xb=f.preloadPlugins);f.print&&(p=f.print); -f.printErr&&(t=f.printErr);f.wasmBinary&&(pa=f.wasmBinary);Object.getOwnPropertyDescriptor(f,"fetchSettings")&&v("`Module.fetchSettings` was supplied but `fetchSettings` not included in INCOMING_MODULE_JS_API");f.thisProgram&&(da=f.thisProgram);m("undefined"==typeof f.memoryInitializerPrefixURL,"Module.memoryInitializerPrefixURL option was removed, use Module.locateFile instead");m("undefined"==typeof f.pthreadMainPrefixURL,"Module.pthreadMainPrefixURL option was removed, use Module.locateFile instead"); -m("undefined"==typeof f.cdInitializerPrefixURL,"Module.cdInitializerPrefixURL option was removed, use Module.locateFile instead");m("undefined"==typeof f.filePackagePrefixURL,"Module.filePackagePrefixURL option was removed, use Module.locateFile instead");m("undefined"==typeof f.read,"Module.read option was removed");m("undefined"==typeof f.readAsync,"Module.readAsync option was removed (modify readAsync in JS)");m("undefined"==typeof f.readBinary,"Module.readBinary option was removed (modify readBinary in JS)"); -m("undefined"==typeof f.setWindowTitle,"Module.setWindowTitle option was removed (modify emscripten_set_window_title in JS)");m("undefined"==typeof f.TOTAL_MEMORY,"Module.TOTAL_MEMORY has been renamed Module.INITIAL_MEMORY");m("undefined"==typeof f.ENVIRONMENT,"Module.ENVIRONMENT has been deprecated. To force the environment, use the ENVIRONMENT compile-time option (for example, -sENVIRONMENT=web or -sENVIRONMENT=node)");m("undefined"==typeof f.STACK_SIZE,"STACK_SIZE can no longer be set at runtime. Use -sSTACK_SIZE at link time"); -m("undefined"==typeof f.wasmMemory,"Use of `wasmMemory` detected. Use -sIMPORTED_MEMORY to define wasmMemory externally");m("undefined"==typeof f.INITIAL_MEMORY,"Detected runtime INITIAL_MEMORY setting. Use -sIMPORTED_MEMORY to define wasmMemory dynamically");f.callMain=nc;f.FS=O; -"writeI53ToI64 writeI53ToI64Clamped writeI53ToI64Signaling writeI53ToU64Clamped writeI53ToU64Signaling readI53FromI64 readI53FromU64 convertI32PairToI53 convertI32PairToI53Checked convertU32PairToI53 getTempRet0 setTempRet0 zeroMemory withStackSave inetPton4 inetNtop4 inetPton6 inetNtop6 readSockaddr writeSockaddr readEmAsmArgs jstoi_q autoResumeAudioContext dynCallLegacy getDynCaller dynCall runtimeKeepalivePush runtimeKeepalivePop asmjsMangle HandleAllocator getNativeTypeSize addOnInit addOnPostCtor addOnPreMain addOnExit STACK_SIZE STACK_ALIGN POINTER_SIZE ASSERTIONS ccall cwrap convertJsFunctionToWasm getEmptyTableSlot updateTableMap getFunctionAddress addFunction removeFunction intArrayToString AsciiToString stringToAscii UTF16ToString stringToUTF16 lengthBytesUTF16 UTF32ToString stringToUTF32 lengthBytesUTF32 stringToNewUTF8 writeArrayToMemory registerKeyEventCallback maybeCStringToJsString findEventTarget getBoundingClientRect fillMouseEventData registerMouseEventCallback registerWheelEventCallback registerUiEventCallback registerFocusEventCallback fillDeviceOrientationEventData registerDeviceOrientationEventCallback fillDeviceMotionEventData registerDeviceMotionEventCallback screenOrientation fillOrientationChangeEventData registerOrientationChangeEventCallback fillFullscreenChangeEventData registerFullscreenChangeEventCallback JSEvents_requestFullscreen JSEvents_resizeCanvasForFullscreen registerRestoreOldStyle hideEverythingExceptGivenElement restoreHiddenElements setLetterbox softFullscreenResizeWebGLRenderTarget doRequestFullscreen fillPointerlockChangeEventData registerPointerlockChangeEventCallback registerPointerlockErrorEventCallback requestPointerLock fillVisibilityChangeEventData registerVisibilityChangeEventCallback registerTouchEventCallback fillGamepadEventData registerGamepadEventCallback registerBeforeUnloadEventCallback fillBatteryEventData registerBatteryEventCallback setCanvasElementSize getCanvasElementSize jsStackTrace getCallstack convertPCtoSourceLocation wasiRightsToMuslOFlags wasiOFlagsToMuslOFlags safeSetTimeout setImmediateWrapped safeRequestAnimationFrame clearImmediateWrapped registerPostMainLoop registerPreMainLoop getPromise makePromise idsToPromises makePromiseCallback ExceptionInfo findMatchingCatch Browser_asyncPrepareDataCounter arraySum addDays getSocketFromFD getSocketAddress FS_mkdirTree _setNetworkCallback heapObjectForWebGLType toTypedArrayIndex webgl_enable_ANGLE_instanced_arrays webgl_enable_OES_vertex_array_object webgl_enable_WEBGL_draw_buffers webgl_enable_WEBGL_multi_draw webgl_enable_EXT_polygon_offset_clamp webgl_enable_EXT_clip_control webgl_enable_WEBGL_polygon_mode emscriptenWebGLGet computeUnpackAlignedImageSize colorChannelsInGlTextureFormat emscriptenWebGLGetTexPixelData emscriptenWebGLGetUniform webglGetUniformLocation webglPrepareUniformLocationsBeforeFirstUse webglGetLeftBracePos emscriptenWebGLGetVertexAttrib __glGetActiveAttribOrUniform writeGLArray registerWebGlEventCallback runAndAbortIfError ALLOC_NORMAL ALLOC_STACK allocate writeStringToMemory writeAsciiToMemory demangle stackTrace".split(" ").forEach(function(a){Ba(a,()=> -{var b=`\`${a}\` is a library symbol and not included by default; add it to your library.js __deps or to DEFAULT_LIBRARY_FUNCS_TO_INCLUDE on the command line`,c=a;c.startsWith("_")||(c="$"+a);b+=` (e.g. -sDEFAULT_LIBRARY_FUNCS_TO_INCLUDE='${c}')`;Aa(a)&&(b+=". Alternatively, forcing filesystem support (-sFORCE_FILESYSTEM) can export this for you");z(b)});Da(a)});"run addRunDependency removeRunDependency out err abort wasmMemory wasmExports HEAPF32 HEAPF64 HEAP8 HEAPU8 HEAP16 HEAPU16 HEAP32 HEAPU32 HEAP64 HEAPU64 writeStackCookie checkStackCookie INT53_MAX INT53_MIN bigintToI53Checked stackSave stackRestore stackAlloc ptrToString exitJS getHeapMax growMemory ENV ERRNO_CODES strError DNS Protocols Sockets timers warnOnce readEmAsmArgsArray getExecutableName handleException keepRuntimeAlive callUserCallback maybeExit asyncLoad alignMemory mmapAlloc wasmTable getUniqueRunDependency noExitRuntime addOnPreRun addOnPostRun freeTableIndexes functionsInTableMap setValue getValue PATH PATH_FS UTF8Decoder UTF8ArrayToString UTF8ToString stringToUTF8Array stringToUTF8 lengthBytesUTF8 intArrayFromString UTF16Decoder stringToUTF8OnStack JSEvents specialHTMLTargets findCanvasEventTarget currentFullscreenStrategy restoreOldWindowedStyle UNWIND_CACHE ExitStatus getEnvStrings checkWasiClock doReadv doWritev initRandomFill randomFill emSetImmediate emClearImmediate_deps emClearImmediate promiseMap uncaughtExceptionCount exceptionLast exceptionCaught Browser requestFullscreen requestFullScreen setCanvasSize getUserMedia createContext getPreloadedImageData__data wget MONTH_DAYS_REGULAR MONTH_DAYS_LEAP MONTH_DAYS_REGULAR_CUMULATIVE MONTH_DAYS_LEAP_CUMULATIVE isLeapYear ydayFromDate SYSCALLS preloadPlugins FS_createPreloadedFile FS_preloadFile FS_modeStringToFlags FS_getMode FS_stdin_getChar_buffer FS_stdin_getChar FS_unlink FS_createPath FS_createDevice FS_readFile FS_root FS_mounts FS_devices FS_streams FS_nextInode FS_nameTable FS_currentPath FS_initialized FS_ignorePermissions FS_filesystems FS_syncFSRequests FS_readFiles FS_lookupPath FS_getPath FS_hashName FS_hashAddNode FS_hashRemoveNode FS_lookupNode FS_createNode FS_destroyNode FS_isRoot FS_isMountpoint FS_isFile FS_isDir FS_isLink FS_isChrdev FS_isBlkdev FS_isFIFO FS_isSocket FS_flagsToPermissionString FS_nodePermissions FS_mayLookup FS_mayCreate FS_mayDelete FS_mayOpen FS_checkOpExists FS_nextfd FS_getStreamChecked FS_getStream FS_createStream FS_closeStream FS_dupStream FS_doSetAttr FS_chrdev_stream_ops FS_major FS_minor FS_makedev FS_registerDevice FS_getDevice FS_getMounts FS_syncfs FS_mount FS_unmount FS_lookup FS_mknod FS_statfs FS_statfsStream FS_statfsNode FS_create FS_mkdir FS_mkdev FS_symlink FS_rename FS_rmdir FS_readdir FS_readlink FS_stat FS_fstat FS_lstat FS_doChmod FS_chmod FS_lchmod FS_fchmod FS_doChown FS_chown FS_lchown FS_fchown FS_doTruncate FS_truncate FS_ftruncate FS_utime FS_open FS_close FS_isClosed FS_llseek FS_read FS_write FS_mmap FS_msync FS_ioctl FS_writeFile FS_cwd FS_chdir FS_createDefaultDirectories FS_createDefaultDevices FS_createSpecialDirectories FS_createStandardStreams FS_staticInit FS_init FS_quit FS_findObject FS_analyzePath FS_createFile FS_createDataFile FS_forceLoadFile FS_createLazyFile FS_absolutePath FS_createFolder FS_createLink FS_joinPath FS_mmapAlloc FS_standardizePath MEMFS TTY PIPEFS SOCKFS tempFixedLengthArray miniTempWebGLFloatBuffers miniTempWebGLIntBuffers GL AL GLUT EGL GLEW IDBStore SDL SDL_gfx allocateUTF8 allocateUTF8OnStack print printErr jstoi_s".split(" ").forEach(Da); -var oc=f._main=y("_main"),Tb=y("_strerror"),Ub=y("_fflush"),ta=y("_emscripten_stack_get_end"),pc=y("__emscripten_timeout"),Y=y("_setThrew"),qc=y("_emscripten_stack_init"),Z=y("__emscripten_stack_restore"),rc=y("__emscripten_stack_alloc"),X=y("_emscripten_stack_get_current"),dynCall_v=y("dynCall_v"),dynCall_iii=y("dynCall_iii"),sc=y("dynCall_viii"),tc=y("dynCall_iiii"),uc=y("dynCall_ii"),dynCall_vi=y("dynCall_vi"),vc=y("dynCall_iiiii"),wc=y("dynCall_iiiiiiiii"),dynCall_vii=y("dynCall_vii"),xc=y("dynCall_iiji"), -yc=y("dynCall_viiii"),zc=y("dynCall_viiiii"),Ac=y("dynCall_viiiiii"),Oc={__assert_fail:(a,b,c,d)=>v(`Assertion failed: ${M(a)}, at: `+[b?M(b):"unknown filename",c,d?M(d):"unknown function"]),__syscall_dup:function(a){try{var b=U(a);return Jb(b).fd}catch(c){if("undefined"==typeof O||"ErrnoError"!==c.name)throw c;return-c.l}},__syscall_dup3:function(a,b,c){try{var d=U(a);m(!c);if(d.fd===b)return-28;if(0>b||b>=O.aa)return-8;var e=O.ga(b);e&&O.close(e);return Jb(d,b).fd}catch(g){if("undefined"==typeof O|| -"ErrnoError"!==g.name)throw g;return-g.l}},__syscall_fcntl64:function(a,b,c){Xb=c;try{var d=U(a);switch(b){case 0:var e=V();if(0>e)break;for(;O.streams[e];)e++;return Jb(d,e).fd;case 1:case 2:return 0;case 3:return d.flags;case 4:return e=V(),d.flags|=e,0;case 12:return e=V(),Ia[e+0>>1]=2,0;case 13:case 14:return 0}return-28}catch(g){if("undefined"==typeof O||"ErrnoError"!==g.name)throw g;return-g.l}},__syscall_fstat64:function(a,b){try{return Wb(b,O.fstat(a))}catch(c){if("undefined"==typeof O||"ErrnoError"!== -c.name)throw c;return-c.l}},__syscall_getdents64:function(a,b,c){try{var d=U(a);d.J||(d.J=O.readdir(d.path));a=0;var e=O.s(d,0,1),g=Math.floor(e/280),h=Math.min(d.J.length,g+Math.floor(c/280));for(c=g;c>3]=BigInt(r);C[b+a+8>>3]=BigInt(280*(c+ -1));Ia[b+a+16>>1]=280;A[b+a+18]=q;W(l,b+a+19,256);a+=280}O.s(d,280*c,0);return a}catch(n){if("undefined"==typeof O||"ErrnoError"!==n.name)throw n;return-n.l}},__syscall_ioctl:function(a,b,c){Xb=c;try{var d=U(a);switch(b){case 21509:return d.tty?0:-59;case 21505:if(!d.tty)return-59;if(d.tty.H.xa){a=[3,28,127,21,4,0,1,0,17,19,26,0,18,15,23,22,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0];var e=V();B[e>>2]=25856;B[e+4>>2]=5;B[e+8>>2]=191;B[e+12>>2]=35387;for(var g=0;32>g;g++)A[e+g+17]=a[g]||0}return 0;case 21510:case 21511:case 21512:return d.tty? -0:-59;case 21506:case 21507:case 21508:if(!d.tty)return-59;if(d.tty.H.ya)for(e=V(),a=[],g=0;32>g;g++)a.push(A[e+g+17]);return 0;case 21519:if(!d.tty)return-59;e=V();return B[e>>2]=0;case 21520:return d.tty?-28:-59;case 21537:case 21531:return e=V(),O.W(d,b,e);case 21523:if(!d.tty)return-59;d.tty.H.za&&(g=[24,80],e=V(),Ia[e>>1]=g[0],Ia[e+2>>1]=g[1]);return 0;case 21524:return d.tty?0:-59;case 21515:return d.tty?0:-59;default:return-28}}catch(h){if("undefined"==typeof O||"ErrnoError"!==h.name)throw h; -return-h.l}},__syscall_lstat64:function(a,b){try{return a=M(a),Wb(b,O.lstat(a))}catch(c){if("undefined"==typeof O||"ErrnoError"!==c.name)throw c;return-c.l}},__syscall_newfstatat:function(a,b,c,d){try{b=M(b);var e=d&256,g=d&4096;d&=-6401;m(!d,`unknown flags in __syscall_newfstatat: ${d}`);b=Vb(a,b,g);return Wb(c,e?O.lstat(b):O.stat(b))}catch(h){if("undefined"==typeof O||"ErrnoError"!==h.name)throw h;return-h.l}},__syscall_openat:function(a,b,c,d){Xb=d;try{b=M(b);b=Vb(a,b);var e=d?V():0;return O.open(b, -c,e).fd}catch(g){if("undefined"==typeof O||"ErrnoError"!==g.name)throw g;return-g.l}},__syscall_renameat:function(a,b,c,d){try{return b=M(b),d=M(d),b=Vb(a,b),d=Vb(c,d),O.rename(b,d),0}catch(e){if("undefined"==typeof O||"ErrnoError"!==e.name)throw e;return-e.l}},__syscall_rmdir:function(a){try{return a=M(a),O.rmdir(a),0}catch(b){if("undefined"==typeof O||"ErrnoError"!==b.name)throw b;return-b.l}},__syscall_stat64:function(a,b){try{return a=M(a),Wb(b,O.stat(a))}catch(c){if("undefined"==typeof O||"ErrnoError"!== -c.name)throw c;return-c.l}},__syscall_unlinkat:function(a,b,c){try{b=M(b);b=Vb(a,b);if(c)if(512===c)O.rmdir(b);else return-28;else O.unlink(b);return 0}catch(d){if("undefined"==typeof O||"ErrnoError"!==d.name)throw d;return-d.l}},_abort_js:()=>v("native code called abort()"),_emscripten_runtime_keepalive_clear:()=>{Ya=!1;Yb=0},_emscripten_throw_longjmp:()=>{throw Infinity;},_gmtime_js:function(a,b){a=-9007199254740992>a||9007199254740992>2]=a.getUTCSeconds(); -B[b+4>>2]=a.getUTCMinutes();B[b+8>>2]=a.getUTCHours();B[b+12>>2]=a.getUTCDate();B[b+16>>2]=a.getUTCMonth();B[b+20>>2]=a.getUTCFullYear()-1900;B[b+24>>2]=a.getUTCDay();B[b+28>>2]=(a.getTime()-Date.UTC(a.getUTCFullYear(),0,1,0,0,0,0))/864E5|0},_localtime_js:function(a,b){a=-9007199254740992>a||9007199254740992>2]=a.getSeconds();B[b+4>>2]=a.getMinutes();B[b+8>>2]=a.getHours();B[b+12>>2]=a.getDate();B[b+16>>2]=a.getMonth();B[b+20>>2]=a.getFullYear()-1900;B[b+24>> -2]=a.getDay();B[b+28>>2]=(Zb(a.getFullYear())?$b:ac)[a.getMonth()]+a.getDate()-1|0;B[b+36>>2]=-(60*a.getTimezoneOffset());var c=(new Date(a.getFullYear(),6,1)).getTimezoneOffset(),d=(new Date(a.getFullYear(),0,1)).getTimezoneOffset();B[b+32>>2]=(c!=d&&a.getTimezoneOffset()==Math.min(d,c))|0},_mktime_js:function(a){var b=new Date(B[a+20>>2]+1900,B[a+16>>2],B[a+12>>2],B[a+8>>2],B[a+4>>2],B[a>>2],0),c=B[a+32>>2],d=b.getTimezoneOffset(),e=(new Date(b.getFullYear(),6,1)).getTimezoneOffset(),g=(new Date(b.getFullYear(), -0,1)).getTimezoneOffset(),h=Math.min(g,e);0>c?B[a+32>>2]=Number(e!=g&&h==d):0>2]=b.getDay();B[a+28>>2]=(Zb(b.getFullYear())?$b:ac)[b.getMonth()]+b.getDate()-1|0;B[a>>2]=b.getSeconds();B[a+4>>2]=b.getMinutes();B[a+8>>2]=b.getHours();B[a+12>>2]=b.getDate();B[a+16>>2]=b.getMonth();B[a+20>>2]=b.getYear();a=b.getTime();return BigInt(isNaN(a)?-1:a/1E3)},_setitimer_js:(a,b)=>{bc[a]&&(clearTimeout(bc[a].id),delete bc[a]);if(!b)return 0; -var c=setTimeout(()=>{m(a in bc);delete bc[a];gc(()=>pc(a,performance.now()))},b);bc[a]={id:c,pb:b};return 0},_tzset_js:(a,b,c,d)=>{var e=(new Date).getFullYear(),g=(new Date(e,0,1)).getTimezoneOffset();e=(new Date(e,6,1)).getTimezoneOffset();x[a>>2]=60*Math.max(g,e);B[b>>2]=Number(g!=e);b=h=>{var l=Math.abs(h);return`UTC${0<=h?"-":"+"}${String(Math.floor(l/60)).padStart(2,"0")}${String(l%60).padStart(2,"0")}`};a=b(g);b=b(e);m(a);m(b);m(16>=jb(a),`timezone name truncated to fit in TZNAME_MAX (${a})`); -m(16>=jb(b),`timezone name truncated to fit in TZNAME_MAX (${b})`);e=a))return 28;C[c>>3]=BigInt(Math.round(1E6*(0===a?Date.now():performance.now())));return 0},emscripten_date_now:()=>Date.now(),emscripten_resize_heap:a=>{var b=Ha.length;a>>>=0;m(a>b);if(2147483648=c;c*=2){var d=b*(1+.2/c);d= -Math.min(d,a+100663296);var e=Math,g=e.min;d=Math.max(a,d);m(65536,"alignment argument is required");e=g.call(e,2147483648,65536*Math.ceil(d/65536));a:{g=e;d=Ga.buffer.byteLength;try{Ga.grow((g-d+65535)/65536|0);Ka();var h=1;break a}catch(l){t(`growMemory: Attempted to grow heap from ${d} bytes to ${g} bytes, but got error: ${l}`)}h=void 0}if(h)return!0}t(`Failed to grow the heap from ${b} bytes to ${e} bytes, not enough memory!`);return!1},environ_get:(a,b)=>{var c=0,d=0,e;for(e of jc()){var g=b+ -c;x[a+d>>2]=g;c+=W(e,g,Infinity)+1;d+=4}return 0},environ_sizes_get:(a,b)=>{var c=jc();x[a>>2]=c.length;a=0;for(var d of c)a+=jb(d)+1;x[b>>2]=a;return 0},exit:fc,fd_close:function(a){try{var b=U(a);O.close(b);return 0}catch(c){if("undefined"==typeof O||"ErrnoError"!==c.name)throw c;return c.l}},fd_pread:function(a,b,c,d,e){d=-9007199254740992>d||9007199254740992>2]=h;return 0}catch(l){if("undefined"==typeof O||"ErrnoError"!== -l.name)throw l;return l.l}},fd_pwrite:function(a,b,c,d,e){d=-9007199254740992>d||9007199254740992>2]=h;return 0}catch(l){if("undefined"==typeof O||"ErrnoError"!==l.name)throw l;return l.l}},fd_read:function(a,b,c,d){try{var e=U(a),g=kc(e,b,c);x[d>>2]=g;return 0}catch(h){if("undefined"==typeof O||"ErrnoError"!==h.name)throw h;return h.l}},fd_seek:function(a,b,c,d){b=-9007199254740992>b||9007199254740992>3]=BigInt(e.position);e.J&&0===b&&0===c&&(e.J=null);return 0}catch(g){if("undefined"==typeof O||"ErrnoError"!==g.name)throw g;return g.l}},fd_write:function(a,b,c,d){try{var e=U(a),g=lc(e,b,c);x[d>>2]=g;return 0}catch(h){if("undefined"==typeof O||"ErrnoError"!==h.name)throw h;return h.l}},invoke_ii:Cc,invoke_iii:Dc,invoke_iiii:Ec,invoke_iiiii:Fc,invoke_iiiiiiiii:Gc,invoke_iiji:Hc,invoke_vi:Ic,invoke_vii:Jc,invoke_viii:Kc,invoke_viiii:Lc,invoke_viiiii:Mc,invoke_viiiiii:Nc, -proc_exit:dc},L=await (async function(){function a(d){L=d.exports;Ga=L.memory;m(Ga,"memory not found in wasm exports");Ka();mc=L.__indirect_function_table;m(mc,"table not found in wasm exports");d=L;f._main=oc=K("__main_argc_argv",2);Tb=K("strerror",1);Ub=K("fflush",1);ta=d.emscripten_stack_get_end;pc=K("_emscripten_timeout",2);Y=K("setThrew",2);qc=d.emscripten_stack_init;Z=d._emscripten_stack_restore;rc=d._emscripten_stack_alloc;X=d.emscripten_stack_get_current;dynCall_v=K("dynCall_v", -1);dynCall_iii=K("dynCall_iii",3);sc=K("dynCall_viii",4);tc=K("dynCall_iiii",4);uc=K("dynCall_ii",2);dynCall_vi=K("dynCall_vi",2);vc=K("dynCall_iiiii",5);wc=K("dynCall_iiiiiiiii",9);dynCall_vii=K("dynCall_vii",3);xc=K("dynCall_iiji",4);yc=K("dynCall_viiii",5);zc=K("dynCall_viiiii",6);Ac=K("dynCall_viiiiii",7);Oa("wasm-instantiate");return L}Na("wasm-instantiate");var b=f,c={env:Oc,wasi_snapshot_preview1:Oc};if(f.instantiateWasm)return new Promise((d,e)=>{try{f.instantiateWasm(c,(g,h)=>{d(a(g,h))})}catch(g){t(`Module.instantiateWasm callback failed with error: ${g}`), -e(g)}});Pa??=f.locateFile?f.locateFile?f.locateFile("gs.wasm",ja):ja+"gs.wasm":(new URL("gs.wasm",import.meta.url)).href;return function(d){m(f===b,"the Module object should not be replaced during async compilation - perhaps the order of HTML elements is wrong?");b=null;return a(d.instance)}(await Sa(c))}());function Ic(a,b){var c=X();try{dynCall_vi(a,b)}catch(d){Z(c);if(d!==d+0)throw d;Y(1,0)}} -function Jc(a,b,c){var d=X();try{dynCall_vii(a,b,c)}catch(e){Z(d);if(e!==e+0)throw e;Y(1,0)}}function Cc(a,b){var c=X();try{return uc(a,b)}catch(d){Z(c);if(d!==d+0)throw d;Y(1,0)}}function Ec(a,b,c,d){var e=X();try{return tc(a,b,c,d)}catch(g){Z(e);if(g!==g+0)throw g;Y(1,0)}}function Dc(a,b,c){var d=X();try{return dynCall_iii(a,b,c)}catch(e){Z(d);if(e!==e+0)throw e;Y(1,0)}}function Kc(a,b,c,d){var e=X();try{sc(a,b,c,d)}catch(g){Z(e);if(g!==g+0)throw g;Y(1,0)}} -function Lc(a,b,c,d,e){var g=X();try{yc(a,b,c,d,e)}catch(h){Z(g);if(h!==h+0)throw h;Y(1,0)}}function Hc(a,b,c,d){var e=X();try{return xc(a,b,c,d)}catch(g){Z(e);if(g!==g+0)throw g;Y(1,0)}}function Fc(a,b,c,d,e){var g=X();try{return vc(a,b,c,d,e)}catch(h){Z(g);if(h!==h+0)throw h;Y(1,0)}}function Mc(a,b,c,d,e,g){var h=X();try{zc(a,b,c,d,e,g)}catch(l){Z(h);if(l!==l+0)throw l;Y(1,0)}}function Nc(a,b,c,d,e,g,h){var l=X();try{Ac(a,b,c,d,e,g,h)}catch(r){Z(l);if(r!==r+0)throw r;Y(1,0)}} -function Gc(a,b,c,d,e,g,h,l,r){var q=X();try{return wc(a,b,c,d,e,g,h,l,r)}catch(u){Z(q);if(u!==u+0)throw u;Y(1,0)}}var Pc; -function nc(a=[]){m(0==E,'cannot call main when async dependencies remain! (listen on Module["onRuntimeInitialized"])');m("undefined"===typeof Wa||0==Wa.length,"cannot call main when preRun functions remain to be called");var b=oc;a.unshift(da);var c=a.length,d=rc(4*(c+1)),e=d;a.forEach(h=>{var l=x,r=e>>2,q=jb(h)+1,u=rc(q);W(h,u,q);l[r]=u;e+=4});x[e>>2]=0;try{var g=b(c,d);fc(g,!0);return g}catch(h){return cc(h)}} -function Qc(){function a(){m(!Pc);Pc=!0;f.calledRun=!0;if(!qa){m(!Ja);Ja=!0;ua();f.noFSInit||O.T||yb();L.__wasm_call_ctors();O.ia=!1;ua();Ea?.(f);f.onRuntimeInitialized?.();ya("onRuntimeInitialized");ua();if(f.postRun)for("function"==typeof f.postRun&&(f.postRun=[f.postRun]);f.postRun.length;){var b=f.postRun.shift();Va.push(b)}ya("postRun");Ua(Va)}}if(0{setTimeout(()=>f.setStatus(""),1);a()},1)):a(),ua())}}function ec(){var a=p,b=t,c=!1;p=t=()=>{c=!0};try{Ub(0),["stdout","stderr"].forEach(d=>{(d=Qb("/dev/"+d))&&mb[d.object.rdev]?.output?.length&&(c=!0)})}catch(d){}p=a;t=b;c&&z("stdio streams had content in them that was not flushed. you should set EXIT_RUNTIME to 1 (see the Emscripten FAQ), or make sure to emit a newline when you printf etc.")} -if(f.preInit)for("function"==typeof f.preInit&&(f.preInit=[f.preInit]);0{Ea=a;Fa=b});for(const a of Object.keys(f))a in moduleArg||Object.defineProperty(moduleArg,a,{configurable:!0,get(){v(`Access to module property ('${a}') is no longer possible via the module constructor argument; Instead, use the result of the module constructor.`)}}); -;return moduleRtn}export default Module; diff --git a/public/ghostscript-wasm/gs.wasm b/public/ghostscript-wasm/gs.wasm deleted file mode 100755 index 159cc23..0000000 Binary files a/public/ghostscript-wasm/gs.wasm and /dev/null differ diff --git a/public/images/badge.svg b/public/images/badge.svg new file mode 100644 index 0000000..9a87f3f --- /dev/null +++ b/public/images/badge.svg @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/locales/ar/common.json b/public/locales/ar/common.json new file mode 100644 index 0000000..133e022 --- /dev/null +++ b/public/locales/ar/common.json @@ -0,0 +1,365 @@ +{ + "nav": { + "home": "الرئيسية", + "about": "حول", + "contact": "اتصل بنا", + "licensing": "الترخيص", + "allTools": "جميع الأدوات", + "openMainMenu": "فتح القائمة الرئيسية", + "language": "اللغة" + }, + "donation": { + "message": "أعجبك BentoPDF؟ ساعدنا في إبقائه مجانيًا ومفتوح المصدر!", + "button": "تبرّع" + }, + "hero": { + "title": "مجموعة", + "pdfToolkit": "أدوات PDF", + "builtForPrivacy": "مصمّمة للخصوصية", + "noSignups": "بدون تسجيل", + "unlimitedUse": "استخدام غير محدود", + "worksOffline": "يعمل بدون إنترنت", + "startUsing": "ابدأ الاستخدام الآن" + }, + "usedBy": { + "title": "يستخدمه شركات وأشخاص يعملون في" + }, + "features": { + "title": "لماذا تختار", + "bentoPdf": "BentoPDF؟", + "noSignup": { + "title": "بدون تسجيل", + "description": "ابدأ فورًا، بدون حسابات أو بريد إلكتروني." + }, + "noUploads": { + "title": "بدون رفع ملفات", + "description": "معالجة كاملة على جهازك، ملفاتك لا تغادر جهازك أبدًا." + }, + "foreverFree": { + "title": "مجاني للأبد", + "description": "جميع الأدوات، بدون فترات تجريبية، بدون حواجز دفع." + }, + "noLimits": { + "title": "بدون حدود", + "description": "استخدم بقدر ما تريد، بدون قيود مخفية." + }, + "batchProcessing": { + "title": "معالجة دفعية", + "description": "عالج عددًا غير محدود من ملفات PDF دفعة واحدة." + }, + "lightningFast": { + "title": "سريع كالبرق", + "description": "عالج ملفات PDF فورًا، بدون انتظار أو تأخير." + } + }, + "tools": { + "title": "ابدأ مع", + "toolsLabel": "الأدوات", + "subtitle": "انقر على أداة لفتح رافع الملفات", + "searchPlaceholder": "ابحث عن أداة (مثلاً، 'تقسيم'، 'ترتيب'...)", + "backToTools": "العودة إلى الأدوات", + "firstLoadNotice": "يستغرق التحميل الأول لحظة أثناء تنزيل محرك التحويل. بعد ذلك، ستكون جميع التحميلات فورية." + }, + "upload": { + "clickToSelect": "انقر لاختيار ملف", + "orDragAndDrop": "أو اسحب وأفلت", + "pdfOrImages": "ملفات PDF أو صور", + "filesNeverLeave": "ملفاتك لا تغادر جهازك أبدًا.", + "addMore": "إضافة المزيد من الملفات", + "clearAll": "مسح الكل", + "clearFiles": "مسح الملفات", + "hints": { + "singlePdf": "ملف PDF واحد", + "pdfFile": "ملف PDF", + "multiplePdfs2": "عدة ملفات PDF (اثنان على الأقل)", + "bmpImages": "صور BMP", + "oneOrMorePdfs": "ملف PDF واحد أو أكثر", + "pdfDocuments": "مستندات PDF", + "oneOrMoreCsv": "ملف CSV واحد أو أكثر", + "multiplePdfsSupported": "يدعم عدة ملفات PDF", + "singleOrMultiplePdfs": "يدعم ملف PDF واحد أو أكثر", + "singlePdfFile": "ملف PDF واحد", + "pdfWithForms": "ملف PDF يحتوي على حقول نماذج", + "heicImages": "صور HEIC/HEIF", + "jpgImages": "صور JPG، JPEG، JP2، JPX", + "pdfsOrImages": "ملفات PDF أو صور", + "oneOrMoreOdt": "ملف ODT واحد أو أكثر", + "singlePdfOnly": "ملف PDF واحد فقط", + "pdfFiles": "ملفات PDF", + "multiplePdfs": "عدة ملفات PDF", + "pngImages": "صور PNG", + "pdfFilesOneOrMore": "ملفات PDF (واحد أو أكثر)", + "oneOrMoreRtf": "ملف RTF واحد أو أكثر", + "svgGraphics": "رسومات SVG", + "tiffImages": "صور TIFF", + "webpImages": "صور WebP" + } + }, + "howItWorks": { + "title": "كيف يعمل", + "step1": "انقر أو اسحب وأفلت ملفك للبدء", + "step2": "انقر زر المعالجة للبدء", + "step3": "احفظ ملفك المعالج فورًا" + }, + "relatedTools": { + "title": "أدوات PDF ذات صلة" + }, + "loader": { + "processing": "جارٍ المعالجة..." + }, + "alert": { + "title": "تنبيه", + "ok": "حسنًا" + }, + "preview": { + "title": "معاينة المستند", + "downloadAsPdf": "تنزيل كـ PDF", + "close": "إغلاق" + }, + "settings": { + "title": "الإعدادات", + "shortcuts": "اختصارات لوحة المفاتيح", + "preferences": "التفضيلات", + "displayPreferences": "تفضيلات العرض", + "searchShortcuts": "البحث في الاختصارات...", + "shortcutsInfo": "اضغط مع الاستمرار على المفاتيح لتعيين اختصار. يتم الحفظ تلقائيًا.", + "shortcutsWarning": "⚠️ تجنب اختصارات المتصفح الشائعة (Cmd/Ctrl+W، Cmd/Ctrl+T، Cmd/Ctrl+N إلخ) لأنها قد لا تعمل بشكل موثوق.", + "import": "استيراد", + "export": "تصدير", + "resetToDefaults": "إعادة التعيين إلى الافتراضي", + "fullWidthMode": "وضع العرض الكامل", + "fullWidthDescription": "استخدم عرض الشاشة الكامل لجميع الأدوات بدلاً من حاوية مركزية", + "settingsAutoSaved": "يتم حفظ الإعدادات تلقائيًا", + "clickToSet": "انقر للتعيين", + "pressKeys": "اضغط على المفاتيح...", + "warnings": { + "alreadyInUse": "الاختصار مستخدم بالفعل", + "assignedTo": "مخصص بالفعل لـ:", + "chooseDifferent": "يرجى اختيار اختصار مختلف.", + "reserved": "تحذير اختصار محجوز", + "commonlyUsed": "يُستخدم عادةً لـ:", + "unreliable": "قد لا يعمل هذا الاختصار بشكل موثوق أو قد يتعارض مع سلوك المتصفح/النظام.", + "useAnyway": "هل تريد استخدامه على أي حال؟", + "resetTitle": "إعادة تعيين الاختصارات", + "resetMessage": "هل أنت متأكد من إعادة تعيين جميع الاختصارات إلى الافتراضي؟

لا يمكن التراجع عن هذا الإجراء.", + "importSuccessTitle": "تم الاستيراد بنجاح", + "importSuccessMessage": "تم استيراد الاختصارات بنجاح!", + "importFailTitle": "فشل الاستيراد", + "importFailMessage": "فشل استيراد الاختصارات. تنسيق الملف غير صالح." + } + }, + "warning": { + "title": "تحذير", + "cancel": "إلغاء", + "proceed": "متابعة" + }, + "compliance": { + "title": "بياناتك لا تغادر جهازك أبدًا", + "weKeep": "نحافظ على", + "yourInfoSafe": "أمان معلوماتك", + "byFollowingStandards": "باتباع معايير الأمان العالمية.", + "processingLocal": "تتم جميع المعالجة محليًا على جهازك.", + "gdpr": { + "title": "توافق GDPR", + "description": "يحمي البيانات الشخصية وخصوصية الأفراد داخل الاتحاد الأوروبي." + }, + "ccpa": { + "title": "توافق CCPA", + "description": "يمنح سكان كاليفورنيا حقوقًا حول كيفية جمع واستخدام ومشاركة معلوماتهم الشخصية." + }, + "hipaa": { + "title": "توافق HIPAA", + "description": "يضع ضمانات للتعامل مع المعلومات الصحية الحساسة في نظام الرعاية الصحية الأمريكي." + } + }, + "faq": { + "title": "الأسئلة", + "questions": "الشائعة", + "sectionTitle": "الأسئلة الشائعة", + "isFree": { + "question": "هل BentoPDF مجاني حقًا؟", + "answer": "نعم، بالتأكيد. جميع أدوات BentoPDF مجانية 100% بدون حدود للملفات، بدون تسجيل، وبدون علامات مائية. نؤمن بأن الجميع يستحق الوصول إلى أدوات PDF بسيطة وقوية بدون حواجز دفع." + }, + "areFilesSecure": { + "question": "هل ملفاتي آمنة؟ أين تتم معالجتها؟", + "answer": "ملفاتك آمنة قدر الإمكان لأنها لا تغادر جهازك أبدًا. تتم جميع المعالجة مباشرة في متصفحك (من جانب العميل). لا نقوم أبدًا برفع ملفاتك إلى خادم، لذا تحافظ على خصوصيتك الكاملة والتحكم في مستنداتك." + }, + "platforms": { + "question": "هل يعمل على Mac وWindows والأجهزة المحمولة؟", + "answer": "نعم! بما أن BentoPDF يعمل بالكامل في متصفحك، فهو يعمل على أي نظام تشغيل بمتصفح حديث، بما في ذلك Windows وmacOS وLinux وiOS وAndroid." + }, + "gdprCompliant": { + "question": "هل BentoPDF متوافق مع GDPR؟", + "answer": "نعم. BentoPDF متوافق تمامًا مع GDPR. بما أن جميع معالجة الملفات تتم محليًا في متصفحك ولا نجمع أو ننقل ملفاتك إلى أي خادم، فليس لدينا وصول إلى بياناتك. هذا يضمن أنك دائمًا تتحكم في مستنداتك." + }, + "dataStorage": { + "question": "هل تخزنون أو تتتبعون أيًا من ملفاتي؟", + "answer": "لا. لا نقوم أبدًا بتخزين أو تتبع أو تسجيل ملفاتك. كل ما تفعله على BentoPDF يحدث في ذاكرة متصفحك ويختفي بمجرد إغلاق الصفحة. لا يوجد رفع، لا سجلات، ولا خوادم معنية." + }, + "different": { + "question": "ما الذي يميز BentoPDF عن أدوات PDF الأخرى؟", + "answer": "معظم أدوات PDF ترفع ملفاتك إلى خادم للمعالجة. BentoPDF لا يفعل ذلك أبدًا. نستخدم تقنيات ويب حديثة وآمنة لمعالجة ملفاتك مباشرة في متصفحك. هذا يعني أداءً أسرع، خصوصية أقوى، وراحة بال كاملة." + }, + "browserBased": { + "question": "كيف تحافظ المعالجة عبر المتصفح على أمانك؟", + "answer": "بالعمل بالكامل داخل متصفحك، يضمن BentoPDF أن ملفاتك لا تغادر جهازك أبدًا. هذا يلغي مخاطر اختراق الخوادم أو تسريب البيانات أو الوصول غير المصرح به. ملفاتك تبقى ملكك — دائمًا." + }, + "analytics": { + "question": "هل تستخدمون ملفات تعريف الارتباط أو التحليلات لتتبعي؟", + "answer": "نحن نهتم بخصوصيتك. BentoPDF لا يتتبع المعلومات الشخصية. نستخدم Simple Analytics فقط لرؤية عدد الزيارات المجهولة. هذا يعني أننا نعرف عدد المستخدمين الذين يزورون موقعنا، لكننا لا نعرف أبدًا من أنت. Simple Analytics متوافق تمامًا مع GDPR ويحترم خصوصيتك." + } + }, + "testimonials": { + "title": "ماذا يقول", + "users": "مستخدمونا", + "say": "" + }, + "support": { + "title": "أعجبك عملنا؟", + "description": "BentoPDF مشروع شغف، صُمم لتوفير مجموعة أدوات PDF مجانية وخاصة وقوية للجميع. إذا وجدته مفيدًا، فكّر في دعم تطويره. كل قهوة تساعد!", + "buyMeCoffee": "اشترِ لي قهوة" + }, + "footer": { + "copyright": "© 2026 BentoPDF. جميع الحقوق محفوظة.", + "version": "الإصدار", + "company": "الشركة", + "aboutUs": "من نحن", + "faqLink": "الأسئلة الشائعة", + "contactUs": "اتصل بنا", + "legal": "قانوني", + "termsAndConditions": "الشروط والأحكام", + "privacyPolicy": "سياسة الخصوصية", + "followUs": "تابعنا" + }, + "merge": { + "title": "دمج ملفات PDF", + "description": "ادمج ملفات كاملة، أو حدد صفحات معينة لدمجها في مستند جديد.", + "fileMode": "وضع الملفات", + "pageMode": "وضع الصفحات", + "howItWorks": "كيف يعمل:", + "fileModeInstructions": [ + "انقر واسحب الأيقونة لتغيير ترتيب الملفات.", + "في حقل \"الصفحات\" لكل ملف، يمكنك تحديد نطاقات (مثلاً، \"1-3, 5\") لدمج تلك الصفحات فقط.", + "اترك حقل \"الصفحات\" فارغًا لتضمين جميع صفحات ذلك الملف." + ], + "pageModeInstructions": [ + "جميع الصفحات من ملفات PDF المرفوعة تظهر أدناه.", + "ما عليك سوى سحب وإفلات الصور المصغرة للصفحات لإنشاء الترتيب الذي تريده لملفك الجديد." + ], + "mergePdfs": "دمج ملفات PDF" + }, + "common": { + "page": "صفحة", + "pages": "صفحات", + "of": "من", + "download": "تنزيل", + "cancel": "إلغاء", + "save": "حفظ", + "delete": "حذف", + "edit": "تعديل", + "add": "إضافة", + "remove": "إزالة", + "loading": "جارٍ التحميل...", + "error": "خطأ", + "success": "تم بنجاح", + "file": "ملف", + "files": "ملفات", + "close": "إغلاق" + }, + "about": { + "hero": { + "title": "نؤمن بأن أدوات PDF يجب أن تكون", + "subtitle": "سريعة، خاصة، ومجانية.", + "noCompromises": "بدون تنازلات." + }, + "mission": { + "title": "مهمتنا", + "description": "تقديم أشمل مجموعة أدوات PDF تحترم خصوصيتك ولا تطلب أي مقابل. نؤمن بأن أدوات المستندات الأساسية يجب أن تكون متاحة للجميع، في كل مكان، بدون عوائق." + }, + "philosophy": { + "label": "فلسفتنا الأساسية", + "title": "الخصوصية أولاً. دائمًا.", + "description": "في عصر أصبحت فيه البيانات سلعة، نتبع نهجًا مختلفًا. تتم جميع معالجة أدوات Bentopdf محليًا في متصفحك. هذا يعني أن ملفاتك لا تلمس خوادمنا أبدًا، ولا نرى مستنداتك، ولا نتتبع ما تفعله. مستنداتك تبقى خاصة تمامًا وبشكل قاطع. إنها ليست مجرد ميزة؛ إنها أساسنا." + }, + "whyBentopdf": { + "title": "لماذا", + "speed": { + "title": "مصمّم للسرعة", + "description": "لا انتظار لرفع أو تنزيل من خادم. بمعالجة الملفات مباشرة في متصفحك باستخدام تقنيات ويب حديثة مثل WebAssembly، نقدم سرعة لا مثيل لها لجميع أدواتنا." + }, + "free": { + "title": "مجاني بالكامل", + "description": "لا فترات تجريبية، لا اشتراكات، لا رسوم مخفية، ولا ميزات \"مميزة\" محتجزة. نؤمن بأن أدوات PDF القوية يجب أن تكون خدمة عامة، وليست مركز ربح." + }, + "noAccount": { + "title": "لا حاجة لحساب", + "description": "ابدأ باستخدام أي أداة فورًا. لا نحتاج بريدك الإلكتروني أو كلمة مرور أو أي معلومات شخصية. سير عملك يجب أن يكون سلسًا ومجهولاً." + }, + "openSource": { + "title": "روح المصدر المفتوح", + "description": "مبني بشفافية. نستفيد من مكتبات مفتوحة المصدر رائعة مثل PDF-lib وPDF.js، ونؤمن بالجهد المجتمعي لجعل الأدوات القوية متاحة للجميع." + } + }, + "cta": { + "title": "مستعد للبدء؟", + "description": "انضم إلى آلاف المستخدمين الذين يثقون بـ Bentopdf لاحتياجات مستنداتهم اليومية. اختبر الفرق الذي يمكن أن تحدثه الخصوصية والأداء.", + "button": "استكشف جميع الأدوات" + } + }, + "contact": { + "title": "تواصل معنا", + "subtitle": "يسعدنا سماعك. سواء كان لديك سؤال أو ملاحظة أو طلب ميزة، لا تتردد في التواصل.", + "email": "يمكنك التواصل معنا مباشرة عبر البريد الإلكتروني على:" + }, + "licensing": { + "title": "الترخيص لـ", + "subtitle": "اختر الترخيص المناسب لاحتياجاتك." + }, + "multiTool": { + "uploadPdfs": "رفع ملفات PDF", + "upload": "رفع", + "addBlankPage": "إضافة صفحة فارغة", + "edit": "تعديل:", + "undo": "تراجع", + "redo": "إعادة", + "reset": "إعادة تعيين", + "selection": "التحديد:", + "selectAll": "تحديد الكل", + "deselectAll": "إلغاء تحديد الكل", + "rotate": "تدوير:", + "rotateLeft": "يسار", + "rotateRight": "يمين", + "transform": "تحويل:", + "duplicate": "تكرار", + "split": "تقسيم", + "clear": "مسح:", + "delete": "حذف", + "download": "تنزيل:", + "downloadSelected": "تنزيل المحدد", + "exportPdf": "تصدير PDF", + "uploadPdfFiles": "اختر ملفات PDF", + "dragAndDrop": "اسحب وأفلت ملفات PDF هنا، أو انقر للاختيار", + "selectFiles": "اختر الملفات", + "renderingPages": "جارٍ عرض الصفحات...", + "actions": { + "duplicatePage": "تكرار هذه الصفحة", + "deletePage": "حذف هذه الصفحة", + "insertPdf": "إدراج PDF بعد هذه الصفحة", + "toggleSplit": "تبديل التقسيم بعد هذه الصفحة" + }, + "pleaseWait": "يرجى الانتظار", + "pagesRendering": "لا تزال الصفحات قيد العرض. يرجى الانتظار...", + "noPagesSelected": "لم يتم تحديد صفحات", + "selectOnePage": "يرجى تحديد صفحة واحدة على الأقل للتنزيل.", + "noPages": "لا توجد صفحات", + "noPagesToExport": "لا توجد صفحات للتصدير.", + "renderingTitle": "جارٍ عرض معاينات الصفحات", + "errorRendering": "فشل عرض الصور المصغرة للصفحات", + "error": "خطأ", + "failedToLoad": "فشل التحميل" + }, + "simpleMode": { + "title": "أدوات PDF", + "subtitle": "اختر أداة للبدء" + } +} diff --git a/public/locales/ar/tools.json b/public/locales/ar/tools.json new file mode 100644 index 0000000..10ec041 --- /dev/null +++ b/public/locales/ar/tools.json @@ -0,0 +1,671 @@ +{ + "categories": { + "popularTools": "الأدوات الشائعة", + "editAnnotate": "تعديل وتعليق", + "convertToPdf": "تحويل إلى PDF", + "convertFromPdf": "تحويل من PDF", + "organizeManage": "تنظيم وإدارة", + "optimizeRepair": "تحسين وإصلاح", + "securePdf": "تأمين PDF" + }, + "pdfMultiTool": { + "name": "أداة PDF المتعددة", + "subtitle": "دمج، تقسيم، تنظيم، حذف، تدوير، إضافة صفحات فارغة، استخراج وتكرار في واجهة موحدة." + }, + "mergePdf": { + "name": "دمج PDF", + "subtitle": "دمج عدة ملفات PDF في ملف واحد. يحافظ على الإشارات المرجعية." + }, + "splitPdf": { + "name": "تقسيم PDF", + "subtitle": "استخراج نطاق من الصفحات في ملف PDF جديد." + }, + "compressPdf": { + "name": "ضغط PDF", + "subtitle": "تقليل حجم ملف PDF الخاص بك.", + "algorithmLabel": "خوارزمية الضغط", + "condense": "تكثيف (موصى به)", + "photon": "فوتون (لملفات PDF كثيرة الصور)", + "condenseInfo": "يستخدم التكثيف ضغطًا متقدمًا: يزيل البيانات الزائدة، يحسّن الصور، يقلّص الخطوط. الأفضل لمعظم ملفات PDF.", + "photonInfo": "يحول فوتون الصفحات إلى صور. استخدمه لملفات PDF كثيرة الصور/الممسوحة ضوئيًا.", + "photonWarning": "تحذير: سيصبح النص غير قابل للتحديد وستتوقف الروابط عن العمل.", + "levelLabel": "مستوى الضغط", + "light": "خفيف (الحفاظ على الجودة)", + "balanced": "متوازن (موصى به)", + "aggressive": "عدواني (ملفات أصغر)", + "extreme": "أقصى (ضغط أقصى)", + "grayscale": "تحويل إلى تدرج الرمادي", + "grayscaleHint": "يقلل حجم الملف بإزالة معلومات الألوان", + "customSettings": "إعدادات مخصصة", + "customSettingsHint": "ضبط دقيق لمعلمات الضغط:", + "outputQuality": "جودة المخرجات", + "resizeImagesTo": "تغيير حجم الصور إلى", + "onlyProcessAbove": "معالجة فقط أعلى من", + "removeMetadata": "إزالة البيانات الوصفية", + "subsetFonts": "تقليص الخطوط (إزالة الحروف غير المستخدمة)", + "removeThumbnails": "إزالة الصور المصغرة المضمنة", + "compressButton": "ضغط PDF" + }, + "pdfEditor": { + "name": "محرر PDF", + "subtitle": "تعليق، تمييز، تنقيح، تعليقات، إضافة أشكال/صور، بحث وعرض ملفات PDF." + }, + "jpgToPdf": { + "name": "JPG إلى PDF", + "subtitle": "إنشاء PDF من صور JPG وJPEG وJPEG2000 (JP2/JPX)." + }, + "signPdf": { + "name": "توقيع PDF", + "subtitle": "ارسم أو اكتب أو ارفع توقيعك." + }, + "cropPdf": { + "name": "قص PDF", + "subtitle": "قص هوامش كل صفحة في ملف PDF الخاص بك." + }, + "extractPages": { + "name": "استخراج الصفحات", + "subtitle": "حفظ مجموعة من الصفحات كملفات جديدة." + }, + "duplicateOrganize": { + "name": "تكرار وتنظيم", + "subtitle": "تكرار وإعادة ترتيب وحذف الصفحات." + }, + "deletePages": { + "name": "حذف الصفحات", + "subtitle": "إزالة صفحات محددة من مستندك." + }, + "editBookmarks": { + "name": "تعديل الإشارات المرجعية", + "subtitle": "إضافة وتعديل واستيراد وحذف واستخراج الإشارات المرجعية في PDF." + }, + "tableOfContents": { + "name": "جدول المحتويات", + "subtitle": "إنشاء صفحة جدول محتويات من إشارات PDF المرجعية." + }, + "pageNumbers": { + "name": "أرقام الصفحات", + "subtitle": "إدراج أرقام الصفحات في مستندك." + }, + "batesNumbering": { + "name": "ترقيم بيتس", + "subtitle": "إضافة أرقام بيتس التسلسلية عبر ملف PDF واحد أو أكثر." + }, + "addWatermark": { + "name": "إضافة علامة مائية", + "subtitle": "ختم نص أو صورة على صفحات PDF الخاصة بك.", + "applyToAllPages": "تطبيق على جميع الصفحات" + }, + "headerFooter": { + "name": "رأس وتذييل", + "subtitle": "إضافة نص في أعلى وأسفل الصفحات." + }, + "invertColors": { + "name": "عكس الألوان", + "subtitle": "إنشاء نسخة \"الوضع الداكن\" من PDF الخاص بك." + }, + "scannerEffect": { + "name": "تأثير الماسح الضوئي", + "subtitle": "اجعل PDF يبدو كمستند ممسوح ضوئيًا.", + "scanSettings": "إعدادات المسح", + "colorspace": "فضاء الألوان", + "gray": "رمادي", + "border": "حد", + "rotate": "تدوير", + "rotateVariance": "تباين التدوير", + "brightness": "السطوع", + "contrast": "التباين", + "blur": "ضبابية", + "noise": "تشويش", + "yellowish": "اصفرار", + "resolution": "الدقة", + "processButton": "تطبيق تأثير الماسح الضوئي" + }, + "adjustColors": { + "name": "ضبط الألوان", + "subtitle": "ضبط دقيق للسطوع والتباين والتشبع والمزيد في PDF.", + "colorSettings": "إعدادات الألوان", + "brightness": "السطوع", + "contrast": "التباين", + "saturation": "التشبع", + "hueShift": "تحويل درجة اللون", + "temperature": "درجة الحرارة", + "tint": "صبغة", + "gamma": "جاما", + "sepia": "بني داكن", + "processButton": "تطبيق تعديلات الألوان" + }, + "backgroundColor": { + "name": "لون الخلفية", + "subtitle": "تغيير لون خلفية PDF الخاص بك." + }, + "changeTextColor": { + "name": "تغيير لون النص", + "subtitle": "تغيير لون النص في PDF الخاص بك." + }, + "addStamps": { + "name": "إضافة أختام", + "subtitle": "إضافة أختام صور إلى PDF باستخدام شريط أدوات التعليقات.", + "usernameLabel": "اسم مستخدم الختم", + "usernamePlaceholder": "أدخل اسمك (للأختام)", + "usernameHint": "سيظهر هذا الاسم على الأختام التي تنشئها." + }, + "removeAnnotations": { + "name": "إزالة التعليقات", + "subtitle": "حذف التعليقات والتمييز والروابط." + }, + "pdfFormFiller": { + "name": "ملء نماذج PDF", + "subtitle": "ملء النماذج مباشرة في المتصفح. يدعم أيضًا نماذج XFA." + }, + "createPdfForm": { + "name": "إنشاء نموذج PDF", + "subtitle": "إنشاء نماذج PDF قابلة للتعبئة مع حقول نص بالسحب والإفلات." + }, + "removeBlankPages": { + "name": "إزالة الصفحات الفارغة", + "subtitle": "اكتشاف وحذف الصفحات الفارغة تلقائيًا.", + "sensitivityHint": "أعلى = أكثر صرامة، فقط الصفحات الفارغة تمامًا. أقل = يسمح بصفحات تحتوي على بعض المحتوى." + }, + "imageToPdf": { + "name": "صور إلى PDF", + "subtitle": "تحويل JPG وPNG وBMP وGIF وTIFF وPNM وPGM وPBM وPPM وPAM وJXR وJPX وJP2 وPSD وSVG وHEIC وWebP إلى PDF." + }, + "pngToPdf": { + "name": "PNG إلى PDF", + "subtitle": "إنشاء PDF من صورة PNG واحدة أو أكثر." + }, + "webpToPdf": { + "name": "WebP إلى PDF", + "subtitle": "إنشاء PDF من صورة WebP واحدة أو أكثر." + }, + "svgToPdf": { + "name": "SVG إلى PDF", + "subtitle": "إنشاء PDF من صورة SVG واحدة أو أكثر." + }, + "bmpToPdf": { + "name": "BMP إلى PDF", + "subtitle": "إنشاء PDF من صورة BMP واحدة أو أكثر." + }, + "heicToPdf": { + "name": "HEIC إلى PDF", + "subtitle": "إنشاء PDF من صورة HEIC واحدة أو أكثر." + }, + "tiffToPdf": { + "name": "TIFF إلى PDF", + "subtitle": "إنشاء PDF من صورة TIFF واحدة أو أكثر." + }, + "textToPdf": { + "name": "نص إلى PDF", + "subtitle": "تحويل ملف نص عادي إلى PDF." + }, + "jsonToPdf": { + "name": "JSON إلى PDF", + "subtitle": "تحويل ملفات JSON إلى تنسيق PDF." + }, + "pdfToJpg": { + "name": "PDF إلى JPG", + "subtitle": "تحويل كل صفحة PDF إلى صورة JPG." + }, + "pdfToPng": { + "name": "PDF إلى PNG", + "subtitle": "تحويل كل صفحة PDF إلى صورة PNG." + }, + "pdfToWebp": { + "name": "PDF إلى WebP", + "subtitle": "تحويل كل صفحة PDF إلى صورة WebP." + }, + "pdfToBmp": { + "name": "PDF إلى BMP", + "subtitle": "تحويل كل صفحة PDF إلى صورة BMP." + }, + "pdfToTiff": { + "name": "PDF إلى TIFF", + "subtitle": "تحويل كل صفحة PDF إلى صورة TIFF." + }, + "pdfToGreyscale": { + "name": "PDF إلى تدرج الرمادي", + "subtitle": "تحويل جميع الألوان إلى أبيض وأسود." + }, + "pdfToJson": { + "name": "PDF إلى JSON", + "subtitle": "تحويل ملفات PDF إلى تنسيق JSON." + }, + "ocrPdf": { + "name": "التعرف الضوئي على PDF", + "subtitle": "اجعل PDF قابلاً للبحث والنسخ." + }, + "alternateMix": { + "name": "تبديل ومزج الصفحات", + "subtitle": "دمج ملفات PDF بتبديل الصفحات من كل ملف. يحافظ على الإشارات المرجعية." + }, + "addAttachments": { + "name": "إضافة مرفقات", + "subtitle": "تضمين ملف واحد أو أكثر في PDF الخاص بك." + }, + "extractAttachments": { + "name": "استخراج المرفقات", + "subtitle": "استخراج جميع الملفات المضمنة من PDF كملف ZIP." + }, + "editAttachments": { + "name": "تعديل المرفقات", + "subtitle": "عرض أو إزالة المرفقات في PDF الخاص بك." + }, + "dividePages": { + "name": "تقسيم الصفحات", + "subtitle": "تقسيم الصفحات أفقيًا أو عموديًا." + }, + "addBlankPage": { + "name": "إضافة صفحة فارغة", + "subtitle": "إدراج صفحة فارغة في أي مكان في PDF الخاص بك." + }, + "reversePages": { + "name": "عكس الصفحات", + "subtitle": "عكس ترتيب جميع الصفحات في مستندك." + }, + "rotatePdf": { + "name": "تدوير PDF", + "subtitle": "تدوير الصفحات بزيادات 90 درجة." + }, + "rotateCustom": { + "name": "تدوير بزاوية مخصصة", + "subtitle": "تدوير الصفحات بأي زاوية مخصصة." + }, + "nUpPdf": { + "name": "N-Up PDF", + "subtitle": "ترتيب عدة صفحات على ورقة واحدة." + }, + "combineToSinglePage": { + "name": "دمج في صفحة واحدة", + "subtitle": "دمج جميع الصفحات في تمرير مستمر واحد." + }, + "viewMetadata": { + "name": "عرض البيانات الوصفية", + "subtitle": "فحص الخصائص المخفية لملف PDF الخاص بك." + }, + "editMetadata": { + "name": "تعديل البيانات الوصفية", + "subtitle": "تغيير المؤلف والعنوان والخصائص الأخرى." + }, + "pdfsToZip": { + "name": "PDF إلى ZIP", + "subtitle": "تجميع عدة ملفات PDF في أرشيف ZIP." + }, + "comparePdfs": { + "name": "مقارنة ملفات PDF", + "subtitle": "مقارنة ملفي PDF جنبًا إلى جنب.", + "firstPdf": "ملف PDF الأول", + "secondPdf": "ملف PDF الثاني", + "clickOrDrop": "انقر أو أفلت", + "page": "الصفحة", + "overlay": "تراكب", + "sideBySide": "جنبًا إلى جنب", + "flicker": "وميض", + "syncScroll": "مزامنة التمرير", + "export": "تصدير", + "exportAsPdf": "تصدير كملف PDF", + "splitView": "عرض مقسوم", + "alternating": "بالتناوب", + "leftDocument": "المستند الأيسر", + "rightDocument": "المستند الأيمن", + "original": "الأصلي", + "modified": "المعدل", + "searchChanges": "ابحث في التغييرات...", + "deleted": "محذوف", + "added": "مضاف", + "prevPage": "الصفحة السابقة", + "nextPage": "الصفحة التالية", + "prevChange": "التغيير السابق", + "nextChange": "التغيير التالي", + "uploadTwoPdfs": "حمّل ملفي PDF لرؤية الاختلافات.", + "noDifferences": "لم يتم اكتشاف اختلافات في هذه الصفحة.", + "noMatchingChanges": "لا توجد تغييرات تطابق عامل التصفية الحالي.", + "pageNotExist": "الصفحة {{page}} غير موجودة في ملف PDF هذا.", + "noPairedPage": "لا توجد صفحة مقترنة لهذا الجانب.", + "buildingModel": "جارٍ إنشاء نموذج إقران الصفحات...", + "indexingPdf": "جارٍ فهرسة PDF {{num}} الصفحة {{page}} من {{total}}...", + "loadingComparison": "جارٍ تحميل المقارنة {{current}} من {{total}}...", + "runningOcr": "جارٍ تشغيل OCR على الصفحة {{page}}...", + "preparingExport": "جارٍ تجهيز تصدير PDF...", + "renderingPage": "جارٍ عرض الصفحة {{current}} من {{total}}...", + "exportError": "خطأ في التصدير", + "exportFailed": "تعذر تصدير ملف PDF المقارن.", + "loadingFile": "جارٍ تحميل {{name}}...", + "invalidFile": "ملف غير صالح", + "invalidFileMsg": "يرجى اختيار ملف PDF صالح.", + "loadError": "تعذر تحميل ملف PDF. قد يكون تالفًا أو محميًا بكلمة مرور." + }, + "posterizePdf": { + "name": "تقسيم PDF إلى ملصقات", + "subtitle": "تقسيم صفحة كبيرة إلى عدة صفحات أصغر." + }, + "fixPageSize": { + "name": "توحيد حجم الصفحة", + "subtitle": "توحيد جميع الصفحات إلى حجم موحد." + }, + "linearizePdf": { + "name": "تحسين PDF للويب", + "subtitle": "تحسين PDF للعرض السريع على الويب." + }, + "pageDimensions": { + "name": "أبعاد الصفحة", + "subtitle": "تحليل حجم الصفحة والاتجاه والوحدات." + }, + "removeRestrictions": { + "name": "إزالة القيود", + "subtitle": "إزالة حماية كلمة المرور وقيود الأمان المرتبطة بملفات PDF الموقعة رقميًا." + }, + "repairPdf": { + "name": "إصلاح PDF", + "subtitle": "استرداد البيانات من ملفات PDF التالفة أو المعطوبة." + }, + "encryptPdf": { + "name": "تشفير PDF", + "subtitle": "قفل PDF بإضافة كلمة مرور." + }, + "sanitizePdf": { + "name": "تنظيف PDF", + "subtitle": "إزالة البيانات الوصفية والتعليقات والبرامج النصية والمزيد." + }, + "decryptPdf": { + "name": "فك تشفير PDF", + "subtitle": "فتح PDF بإزالة حماية كلمة المرور." + }, + "flattenPdf": { + "name": "تسطيح PDF", + "subtitle": "جعل حقول النماذج والتعليقات غير قابلة للتعديل." + }, + "removeMetadata": { + "name": "إزالة البيانات الوصفية", + "subtitle": "حذف البيانات المخفية من PDF الخاص بك." + }, + "changePermissions": { + "name": "تغيير الأذونات", + "subtitle": "تعيين أو تغيير أذونات المستخدم على PDF." + }, + "odtToPdf": { + "name": "ODT إلى PDF", + "subtitle": "تحويل ملفات OpenDocument النصية إلى تنسيق PDF. يدعم عدة ملفات.", + "acceptedFormats": "ملفات ODT", + "convertButton": "تحويل إلى PDF" + }, + "csvToPdf": { + "name": "CSV إلى PDF", + "subtitle": "تحويل ملفات جداول CSV إلى تنسيق PDF. يدعم عدة ملفات.", + "acceptedFormats": "ملفات CSV", + "convertButton": "تحويل إلى PDF" + }, + "rtfToPdf": { + "name": "RTF إلى PDF", + "subtitle": "تحويل مستندات Rich Text إلى PDF. يدعم عدة ملفات.", + "acceptedFormats": "ملفات RTF", + "convertButton": "تحويل إلى PDF" + }, + "wordToPdf": { + "name": "Word إلى PDF", + "subtitle": "تحويل مستندات Word (DOCX، DOC، ODT، RTF) إلى تنسيق PDF. يدعم عدة ملفات.", + "acceptedFormats": "ملفات DOCX، DOC، ODT، RTF", + "convertButton": "تحويل إلى PDF" + }, + "excelToPdf": { + "name": "Excel إلى PDF", + "subtitle": "تحويل جداول Excel (XLSX، XLS، ODS، CSV) إلى تنسيق PDF. يدعم عدة ملفات.", + "acceptedFormats": "ملفات XLSX، XLS، ODS، CSV", + "convertButton": "تحويل إلى PDF" + }, + "powerpointToPdf": { + "name": "PowerPoint إلى PDF", + "subtitle": "تحويل عروض PowerPoint (PPTX، PPT، ODP) إلى تنسيق PDF. يدعم عدة ملفات.", + "acceptedFormats": "ملفات PPTX، PPT، ODP", + "convertButton": "تحويل إلى PDF" + }, + "markdownToPdf": { + "name": "Markdown إلى PDF", + "subtitle": "اكتب أو الصق Markdown وصدّره كـ PDF منسق بشكل جميل.", + "paneMarkdown": "Markdown", + "panePreview": "معاينة", + "btnUpload": "رفع", + "btnSyncScroll": "مزامنة التمرير", + "btnSettings": "الإعدادات", + "btnExportPdf": "تصدير PDF", + "settingsTitle": "إعدادات Markdown", + "settingsPreset": "إعداد مسبق", + "presetDefault": "افتراضي (شبيه بـ GFM)", + "presetCommonmark": "CommonMark (صارم)", + "presetZero": "أدنى (بدون ميزات)", + "settingsOptions": "خيارات Markdown", + "optAllowHtml": "السماح بوسوم HTML", + "optBreaks": "تحويل أسطر جديدة إلى
", + "optLinkify": "تحويل الروابط تلقائيًا", + "optTypographer": "المطبعي (علامات اقتباس ذكية، إلخ)" + }, + "pdfBooklet": { + "name": "كتيّب PDF", + "subtitle": "إعادة ترتيب الصفحات لطباعة كتيّب مزدوج الوجه. اطوِ ودبّس لإنشاء كتيّب.", + "howItWorks": "كيف يعمل:", + "step1": "ارفع ملف PDF.", + "step2": "سيتم إعادة ترتيب الصفحات بترتيب الكتيّب.", + "step3": "اطبع مزدوج الوجه، اقلب على الحافة القصيرة، اطوِ ودبّس.", + "paperSize": "حجم الورق", + "orientation": "الاتجاه", + "portrait": "عمودي", + "landscape": "أفقي", + "pagesPerSheet": "صفحات لكل ورقة", + "createBooklet": "إنشاء كتيّب", + "processing": "جارٍ المعالجة...", + "pageCount": "سيتم تعبئة عدد الصفحات إلى مضاعف 4 إذا لزم الأمر." + }, + "xpsToPdf": { + "name": "XPS إلى PDF", + "subtitle": "تحويل مستندات XPS/OXPS إلى تنسيق PDF. يدعم عدة ملفات.", + "acceptedFormats": "ملفات XPS، OXPS", + "convertButton": "تحويل إلى PDF" + }, + "mobiToPdf": { + "name": "MOBI إلى PDF", + "subtitle": "تحويل كتب MOBI الإلكترونية إلى تنسيق PDF. يدعم عدة ملفات.", + "acceptedFormats": "ملفات MOBI", + "convertButton": "تحويل إلى PDF" + }, + "epubToPdf": { + "name": "EPUB إلى PDF", + "subtitle": "تحويل كتب EPUB الإلكترونية إلى تنسيق PDF. يدعم عدة ملفات.", + "acceptedFormats": "ملفات EPUB", + "convertButton": "تحويل إلى PDF" + }, + "fb2ToPdf": { + "name": "FB2 إلى PDF", + "subtitle": "تحويل كتب FictionBook (FB2) الإلكترونية إلى تنسيق PDF. يدعم عدة ملفات.", + "acceptedFormats": "ملفات FB2", + "convertButton": "تحويل إلى PDF" + }, + "cbzToPdf": { + "name": "CBZ إلى PDF", + "subtitle": "تحويل أرشيفات القصص المصورة (CBZ/CBR) إلى تنسيق PDF. يدعم عدة ملفات.", + "acceptedFormats": "ملفات CBZ، CBR", + "convertButton": "تحويل إلى PDF" + }, + "wpdToPdf": { + "name": "WPD إلى PDF", + "subtitle": "تحويل مستندات WordPerfect (WPD) إلى تنسيق PDF. يدعم عدة ملفات.", + "acceptedFormats": "ملفات WPD", + "convertButton": "تحويل إلى PDF" + }, + "wpsToPdf": { + "name": "WPS إلى PDF", + "subtitle": "تحويل مستندات WPS Office إلى تنسيق PDF. يدعم عدة ملفات.", + "acceptedFormats": "ملفات WPS", + "convertButton": "تحويل إلى PDF" + }, + "xmlToPdf": { + "name": "XML إلى PDF", + "subtitle": "تحويل مستندات XML إلى تنسيق PDF. يدعم عدة ملفات.", + "acceptedFormats": "ملفات XML", + "convertButton": "تحويل إلى PDF" + }, + "pagesToPdf": { + "name": "Pages إلى PDF", + "subtitle": "تحويل مستندات Apple Pages إلى تنسيق PDF. يدعم عدة ملفات.", + "acceptedFormats": "ملفات Pages", + "convertButton": "تحويل إلى PDF" + }, + "odgToPdf": { + "name": "ODG إلى PDF", + "subtitle": "تحويل ملفات OpenDocument Graphics (ODG) إلى تنسيق PDF. يدعم عدة ملفات.", + "acceptedFormats": "ملفات ODG", + "convertButton": "تحويل إلى PDF" + }, + "odsToPdf": { + "name": "ODS إلى PDF", + "subtitle": "تحويل جداول OpenDocument (ODS) إلى تنسيق PDF. يدعم عدة ملفات.", + "acceptedFormats": "ملفات ODS", + "convertButton": "تحويل إلى PDF" + }, + "odpToPdf": { + "name": "ODP إلى PDF", + "subtitle": "تحويل عروض OpenDocument (ODP) إلى تنسيق PDF. يدعم عدة ملفات.", + "acceptedFormats": "ملفات ODP", + "convertButton": "تحويل إلى PDF" + }, + "pubToPdf": { + "name": "PUB إلى PDF", + "subtitle": "تحويل ملفات Microsoft Publisher (PUB) إلى تنسيق PDF. يدعم عدة ملفات.", + "acceptedFormats": "ملفات PUB", + "convertButton": "تحويل إلى PDF" + }, + "vsdToPdf": { + "name": "VSD إلى PDF", + "subtitle": "تحويل ملفات Microsoft Visio (VSD، VSDX) إلى تنسيق PDF. يدعم عدة ملفات.", + "acceptedFormats": "ملفات VSD، VSDX", + "convertButton": "تحويل إلى PDF" + }, + "psdToPdf": { + "name": "PSD إلى PDF", + "subtitle": "تحويل ملفات Adobe Photoshop (PSD) إلى تنسيق PDF. يدعم عدة ملفات.", + "acceptedFormats": "ملفات PSD", + "convertButton": "تحويل إلى PDF" + }, + "pdfToSvg": { + "name": "PDF إلى SVG", + "subtitle": "تحويل كل صفحة من ملف PDF إلى رسومات متجهة قابلة للتوسع (SVG) بجودة مثالية بأي حجم." + }, + "extractTables": { + "name": "استخراج جداول PDF", + "subtitle": "استخراج الجداول من ملفات PDF وتصديرها كـ CSV أو JSON أو Markdown." + }, + "pdfToCsv": { + "name": "PDF إلى CSV", + "subtitle": "استخراج الجداول من PDF وتحويلها إلى تنسيق CSV." + }, + "pdfToExcel": { + "name": "PDF إلى Excel", + "subtitle": "استخراج الجداول من PDF وتحويلها إلى تنسيق Excel (XLSX)." + }, + "pdfToText": { + "name": "PDF إلى نص", + "subtitle": "استخراج النص من ملفات PDF وحفظه كنص عادي (.txt). يدعم عدة ملفات.", + "note": "تعمل هذه الأداة فقط مع ملفات PDF المنشأة رقميًا. للمستندات الممسوحة ضوئيًا أو ملفات PDF المبنية على الصور، استخدم أداة التعرف الضوئي.", + "convertButton": "استخراج النص" + }, + "digitalSignPdf": { + "name": "توقيع رقمي لـ PDF", + "pageTitle": "توقيع رقمي لـ PDF - إضافة توقيع تشفيري | BentoPDF", + "subtitle": "إضافة توقيع رقمي تشفيري إلى PDF باستخدام شهادات X.509. يدعم تنسيقات PKCS#12 (.pfx، .p12) وPEM. مفتاحك الخاص لا يغادر متصفحك أبدًا.", + "certificateSection": "الشهادة", + "uploadCert": "رفع شهادة (.pfx، .p12)", + "certPassword": "كلمة مرور الشهادة", + "certPasswordPlaceholder": "أدخل كلمة مرور الشهادة", + "certInfo": "معلومات الشهادة", + "certSubject": "الموضوع", + "certIssuer": "المُصدر", + "certValidity": "صالحة", + "signatureDetails": "تفاصيل التوقيع (اختياري)", + "reason": "السبب", + "reasonPlaceholder": "مثلاً، أوافق على هذا المستند", + "location": "الموقع", + "locationPlaceholder": "مثلاً، نيويورك، الولايات المتحدة", + "contactInfo": "معلومات الاتصال", + "contactPlaceholder": "مثلاً، email@example.com", + "applySignature": "تطبيق التوقيع الرقمي", + "successMessage": "تم توقيع PDF بنجاح! يمكن التحقق من التوقيع في أي قارئ PDF." + }, + "validateSignaturePdf": { + "name": "التحقق من توقيع PDF", + "pageTitle": "التحقق من توقيع PDF - التحقق من التوقيعات الرقمية | BentoPDF", + "subtitle": "التحقق من التوقيعات الرقمية في ملفات PDF. تحقق من صلاحية الشهادة، واعرض تفاصيل الموقّع، وتأكد من سلامة المستند. تتم جميع المعالجة في متصفحك." + }, + "emailToPdf": { + "name": "بريد إلكتروني إلى PDF", + "subtitle": "تحويل ملفات البريد الإلكتروني (EML، MSG) إلى تنسيق PDF. يدعم تصديرات Outlook وتنسيقات البريد القياسية.", + "acceptedFormats": "ملفات EML، MSG", + "convertButton": "تحويل إلى PDF" + }, + "fontToOutline": { + "name": "تحويل الخطوط إلى مخططات", + "subtitle": "تحويل جميع الخطوط إلى مخططات متجهة لعرض متسق على جميع الأجهزة." + }, + "deskewPdf": { + "name": "تصحيح انحراف PDF", + "subtitle": "تقويم الصفحات الممسوحة ضوئيًا المائلة تلقائيًا باستخدام OpenCV." + }, + "pdfToWord": { + "name": "PDF إلى Word", + "subtitle": "تحويل ملفات PDF إلى مستندات Word قابلة للتعديل." + }, + "extractImages": { + "name": "استخراج الصور", + "subtitle": "استخراج جميع الصور المضمنة من ملفات PDF." + }, + "pdfToMarkdown": { + "name": "PDF إلى Markdown", + "subtitle": "تحويل نصوص وجداول PDF إلى تنسيق Markdown." + }, + "preparePdfForAi": { + "name": "تحضير PDF للذكاء الاصطناعي", + "subtitle": "استخراج محتوى PDF كـ JSON بتنسيق LlamaIndex لخطوط RAG/LLM." + }, + "pdfOcg": { + "name": "طبقات PDF OCG", + "subtitle": "عرض وتبديل وإضافة وحذف طبقات OCG في PDF." + }, + "pdfToPdfa": { + "name": "PDF إلى PDF/A", + "subtitle": "تحويل PDF إلى PDF/A للأرشفة طويلة المدى." + }, + "rasterizePdf": { + "name": "تحويل PDF إلى صور نقطية", + "subtitle": "تحويل PDF إلى PDF قائم على الصور. تسطيح الطبقات وإزالة النص القابل للتحديد." + }, + "pdfWorkflow": { + "name": "منشئ سير عمل PDF", + "subtitle": "بناء خطوط معالجة PDF مخصصة باستخدام محرر عقد مرئي.", + "nodes": "العُقد", + "searchNodes": "البحث في العُقد...", + "run": "تشغيل", + "clear": "مسح", + "save": "حفظ", + "load": "تحميل", + "export": "تصدير", + "import": "استيراد", + "ready": "جاهز", + "settings": "الإعدادات", + "processing": "جارٍ المعالجة...", + "saveTemplate": "حفظ القالب", + "templateName": "اسم القالب", + "templatePlaceholder": "مثلاً سير عمل الفواتير", + "cancel": "إلغاء", + "loadTemplate": "تحميل القالب", + "noTemplates": "لا توجد قوالب محفوظة بعد.", + "ok": "حسنًا", + "workflowCompleted": "اكتمل سير العمل", + "errorDuringExecution": "خطأ أثناء التنفيذ", + "addNodeError": "أضف عقدة واحدة على الأقل لتشغيل سير العمل.", + "needInputOutput": "يحتاج سير العمل إلى عقدة إدخال واحدة وعقدة إخراج واحدة على الأقل للتشغيل.", + "enterName": "يرجى إدخال اسم.", + "templateExists": "يوجد قالب بهذا الاسم بالفعل.", + "templateSaved": "تم حفظ القالب \"{{name}}\".", + "templateLoaded": "تم تحميل القالب \"{{name}}\".", + "failedLoadTemplate": "فشل تحميل القالب.", + "noSettings": "لا توجد إعدادات قابلة للتخصيص لهذه العقدة.", + "advancedSettings": "إعدادات متقدمة" + } +} diff --git a/public/locales/be/common.json b/public/locales/be/common.json new file mode 100644 index 0000000..3161429 --- /dev/null +++ b/public/locales/be/common.json @@ -0,0 +1,365 @@ +{ + "nav": { + "home": "Галоўная", + "about": "Пра нас", + "contact": "Кантакты", + "licensing": "Ліцэнзія", + "allTools": "Усе інструменты", + "openMainMenu": "Адкрыць галоўнае меню", + "language": "Мова" + }, + "donation": { + "message": "Падабаецца BentoPDF? Падтрымайце, каб ён заставаўся бясплатным і з адкрытым зыходным кодам!", + "button": "Ахвяраваць" + }, + "hero": { + "title": "Набор", + "pdfToolkit": "інструментаў PDF", + "builtForPrivacy": "для максімальнай прыватнасці", + "noSignups": "Без рэгістрацыі", + "unlimitedUse": "Без абмежаванняў", + "worksOffline": "Працуе па-за сеткай", + "startUsing": "Пачаць" + }, + "usedBy": { + "title": "Выкарыстоўваецца кампаніямі і людзьмі, якія працуюць у" + }, + "features": { + "title": "Чаму выбіраюць", + "bentoPdf": "BentoPDF?", + "noSignup": { + "title": "Без рэгістрацыі", + "description": "Пачынайце адразу, без уліковага запісу і электроннай пошты." + }, + "noUploads": { + "title": "Без запампоўванняў", + "description": "100% на баку кліента, вашы файлы ніколі не пакідаюць прыладу." + }, + "foreverFree": { + "title": "Заўсёды бясплатна", + "description": "Усе інструменты, ніякіх пробных перыядаў і платных бар'ераў." + }, + "noLimits": { + "title": "Без лімітаў", + "description": "Карыстайцеся колькі заўгодна, без схаваных абмежаванняў." + }, + "batchProcessing": { + "title": "Пакетная апрацоўка", + "description": "Апрацоўвайце неабмежаваную колькасць PDF за адзін раз." + }, + "lightningFast": { + "title": "З хуткасцю маланкі", + "description": "Апрацоўвайце PDF імгненна, без чакання і затрымак." + } + }, + "tools": { + "title": "Пачніце працу з", + "toolsLabel": "інструментамі", + "subtitle": "Націсніце на інструмент, каб адкрыць запампоўшчык", + "searchPlaceholder": "Пошук (напр., \"выдаліць\", \"сціснуць\"...)", + "backToTools": "Назад да інструментаў", + "firstLoadNotice": "Першае адкрыццё займае крыху часу, пакуль загружаецца рухавік канвертацыі. Потым усё будзе адкрывацца імгненна." + }, + "upload": { + "clickToSelect": "Націсніце, каб выбраць файл,", + "orDragAndDrop": "або перацягніце сюды", + "pdfOrImages": "PDF або відарысы", + "filesNeverLeave": "Вашы файлы ніколі не пакідаюць прыладу.", + "addMore": "Дадаць больш файлаў", + "clearAll": "Ачысціць усё", + "clearFiles": "Ачысціць файлы", + "hints": { + "singlePdf": "Адзін файл PDF", + "pdfFile": "Файл PDF", + "multiplePdfs2": "Некалькі файлаў PDF (мінімум 2)", + "bmpImages": "Відарысы BMP", + "oneOrMorePdfs": "Адзін або некалькі файлаў PDF", + "pdfDocuments": "Дакументы PDF", + "oneOrMoreCsv": "Адзін або некалькі файлаў CSV", + "multiplePdfsSupported": "Падтрымліваецца некалькі файлаў PDF", + "singleOrMultiplePdfs": "Падтрымліваецца адзін або некалькі файлаў PDF", + "singlePdfFile": "Адзін файл PDF", + "pdfWithForms": "Файл PDF з палямі формы", + "heicImages": "Відарысы HEIC/HEIF", + "jpgImages": "Відарысы JPG, JPEG, JP2, JPX", + "pdfsOrImages": "PDF або відарысы", + "oneOrMoreOdt": "Адзін або некалькі файлаў ODT", + "singlePdfOnly": "Толькі адзін файл PDF", + "pdfFiles": "Файлы PDF", + "multiplePdfs": "Некалькі файлаў PDF", + "pngImages": "Відарысы PNG", + "pdfFilesOneOrMore": "Файлы PDF (адзін або некалькі)", + "oneOrMoreRtf": "Адзін або некалькі файлаў RTF", + "svgGraphics": "SVG-графіка", + "tiffImages": "Відарысы TIFF", + "webpImages": "Відарысы WebP" + } + }, + "loader": { + "processing": "Апрацоўка..." + }, + "alert": { + "title": "Апавяшчэнне", + "ok": "ОК" + }, + "preview": { + "title": "Папярэдні прагляд дакумента", + "downloadAsPdf": "Спампаваць як PDF", + "close": "Закрыць" + }, + "settings": { + "title": "Налады", + "shortcuts": "Спалучэнні клавіш", + "preferences": "Параметры", + "displayPreferences": "Параметры адлюстравання", + "searchShortcuts": "Пошук спалучэнняў...", + "shortcutsInfo": "Утрымлівайце клавішы, каб задаць спалучэнне. Змены захоўваюцца аўтаматычна.", + "shortcutsWarning": "⚠️ Пазбягайце стандартных спалучэнняў браўзера (Cmd/Ctrl+W, Cmd/Ctrl+T, Cmd/Ctrl+N і інш.), бо яны могуць працаваць ненадзейна.", + "import": "Імпарт", + "export": "Экспарт", + "resetToDefaults": "Скінуць да прадвызначаных", + "fullWidthMode": "На ўсю шырыню", + "fullWidthDescription": "Выкарыстоўваць усю шырыню экрана для ўсіх інструментаў замест цэнтраванага кантэйнера", + "settingsAutoSaved": "Налады захоўваюцца аўтаматычна", + "clickToSet": "Задаць", + "pressKeys": "Націсніце...", + "warnings": { + "alreadyInUse": "Спалучэнне клавіш ужо выкарыстоўваецца", + "assignedTo": "ужо прызначана для:", + "chooseDifferent": "Выберыце іншае спалучэнне.", + "reserved": "Папярэджанне аб зарэзерваваным спалучэнні клавіш", + "commonlyUsed": "часта выкарыстоўваецца для:", + "unreliable": "Гэта спалучэнне клавіш можа працаваць ненадзейна або канфліктаваць з паводзінамі браўзера/сістэмы.", + "useAnyway": "Усё роўна выкарыстоўваць?", + "resetTitle": "Скінуць спалучэнні", + "resetMessage": "Вы ўпэўнены, што хочаце скінуць усе спалучэнні да прадвызначаных значэнняў?

Гэта дзеянне нельга адрабіць.", + "importSuccessTitle": "Імпарт паспяховы", + "importSuccessMessage": "Спалучэнні клавіш паспяхова імпартаваныя!", + "importFailTitle": "Не ўдалося імпартаваць", + "importFailMessage": "Не атрымалася імпартаваць спалучэнні клавіш. Памылковы фармат файла." + } + }, + "warning": { + "title": "Папярэджанне", + "cancel": "Скасаваць", + "proceed": "Працягнуць" + }, + "compliance": { + "title": "Вашы даныя ніколі не пакідаюць прыладу", + "weKeep": "Мы захоўваем", + "yourInfoSafe": "вашу інфармацыю ў бяспецы", + "byFollowingStandards": "паводле глабальных стандартаў.", + "processingLocal": "Уся апрацоўка адбываецца лакальна на вашай прыладзе.", + "gdpr": { + "title": "Адпаведнасць GDPR", + "description": "Ахоўвае персанальныя даныя і прыватнасць людзей у Еўрапейскім Саюзе." + }, + "ccpa": { + "title": "Адпаведнасць CCPA", + "description": "Дае жыхарам Каліфорніі правы ведаць, як збіраецца, выкарыстоўваецца і перадаецца іх персанальная інфармацыя." + }, + "hipaa": { + "title": "Адпаведнасць HIPAA", + "description": "Усталёўвае меры бяспекі для апрацоўкі канфідэнцыйнай медыцынскай інфармацыі ў сістэме аховы здароўя ЗША." + } + }, + "faq": { + "title": "Частыя", + "questions": "пытанні", + "isFree": { + "question": "BentoPDF сапраўды бясплатны?", + "answer": "Так, абсалютна. Усе інструменты BentoPDF на 100% бясплатныя для выкарыстання, без лімітаў файлаў, без рэгістрацыі і без вадзяных знакаў. Мы верым, што кожны мае права доступу да простых і магутных інструментаў PDF без платных бар'ераў." + }, + "areFilesSecure": { + "question": "Ці ў бяспецы мае файлы? Дзе яны апрацоўваюцца?", + "answer": "Вашы файлы ў бяспецы настолькі, наколькі гэта магчыма, бо яны ніколі не пакідаюць ваш камп'ютар. Уся апрацоўка адбываецца непасрэдна ў вашым браўзеры (на баку кліента). Мы ніколі не запампоўваем вашы файлы на сервер, таму вы захоўваеце поўную прыватнасць і кантроль над сваімі дакументамі." + }, + "platforms": { + "question": "Ці працуе BentoPDF на Mac, Windows і мабільных прыладах?", + "answer": "Так! Паколькі BentoPDF працуе выключна ў браўзеры, ён працуе на любой аперацыйнай сістэме з сучасным вэб-браўзерам, уключаючы Windows, macOS, Linux, iOS і Android." + }, + "gdprCompliant": { + "question": "Ці адпавядае BentoPDF GDPR?", + "answer": "Так. BentoPDF цалкам адпавядае GDPR. Паколькі ўся апрацоўка файлаў адбываецца лакальна ў браўзеры і мы ніколі не збіраем і не перадаём вашы файлы на сервер, мы не маем доступу да вашых даных. Гэта гарантуе, што вы заўсёды кантралюеце свае дакументы." + }, + "dataStorage": { + "question": "Вы захоўваеце або адсочваеце мае файлы?", + "answer": "Не. Мы ніколі не захоўваем, не адсочваем і не запісваем звесткі пра вашы файлы. Усё, што вы робіце ў BentoPDF, адбываецца ў памяці вашага браўзера і знікае пасля закрыцця старонкі. Няма запампоўванняў, няма гісторыі і няма сервераў." + }, + "different": { + "question": "Чым BentoPDF адрозніваецца ад іншых інструментаў PDF?", + "answer": "Звычайна інструменты PDF запампоўваюць вашы файлы на сервер для апрацоўкі. BentoPDF ніколі так не робіць. Мы выкарыстоўваем бяспечныя сучасныя вэб-тэхналогіі, каб апрацоўваць вашы файлы непасрэдна ў браўзеры. Гэта азначае большую хуткасць, лепшую прыватнасць і поўны спакой." + }, + "browserBased": { + "question": "Чаму апрацоўка ў браўзеры - гэта бяспечна?", + "answer": "Працуючы цалкам у браўзеры, BentoPDF гарантуе, што вашы файлы ніколі не пакінуць вашу прыладу. Гэта ліквідуе рызыкі ўзлому сервераў, уцечак даных або несанкцыянаванага доступу. Вашы файлы застаюцца вашымі — заўсёды." + }, + "analytics": { + "question": "Вы выкарыстоўваеце cookies або аналітыку, каб сачыць за мной?", + "answer": "Мы дбаем пра вашу прыватнасць. BentoPDF не адсочвае персанальную інфармацыю. Мы выкарыстоўваем Simple Analytics, толькі каб бачыць ананімную статыстыку наведванняў. Гэта значыць, мы ведаем, колькі карыстальнікаў наведвае наш сайт, але ніколі не ведаем, хто вы. Simple Analytics цалкам адпавядае GDPR і шануе вашу прыватнасць." + }, + "sectionTitle": "Частыя пытанні" + }, + "testimonials": { + "title": "Што кажуць", + "users": "нашы карыстальнікі", + "say": " " + }, + "support": { + "title": "Спадабалася мая праца?", + "description": "BentoPDF — гэта праект, створаны на энтузіязме з мэтай, каб кожны меў бясплатны, прыватны і магутны набор інструментаў PDF. Калі ён прыносіць вам карысць, падтрымайце распрацоўку. Дапамагае кожная кава!", + "buyMeCoffee": "Пачастуйце мяне кавай" + }, + "footer": { + "copyright": "© 2026 BentoPDF. Усе правы абаронены.", + "version": "Версія", + "company": "Кампанія", + "aboutUs": "Пра нас", + "faqLink": "Частыя пытанні", + "contactUs": "Звязацца з намі", + "legal": "Прававая інфармацыя", + "termsAndConditions": "Умовы выкарыстання", + "privacyPolicy": "Палітыка прыватнасці", + "followUs": "Сачыце за намі" + }, + "merge": { + "title": "Аб'яднаць PDF", + "description": "Аб'ядноўвайце цэлыя файлы або выбірайце пэўныя старонкі, з якіх будзе складацца новы дакумент.", + "fileMode": "Рэжым файлаў", + "pageMode": "Рэжым старонак", + "howItWorks": "Як гэта працуе:", + "fileModeInstructions": [ + "Націсніце і перацягніце значок, каб змяніць парадак файлаў.", + "У полі \"Старонкі\" для кожнага файла можна задаць дыяпазоны (напр., \"1-3, 5\"), каб аб'яднаць толькі гэтыя старонкі.", + "Пакіньце поле \"Старонкі\" пустым, каб уключыць усе старонкі гэтага файла." + ], + "pageModeInstructions": [ + "Усе старонкі з запампаваных PDF паказаны ніжэй.", + "Проста перацягніце мініяцюры старонак, каб задаць патрэбны парадак для новага файла." + ], + "mergePdfs": "Аб'яднаць PDF" + }, + "common": { + "page": "Старонка", + "pages": "Старонкі", + "of": "з", + "download": "Спампаваць", + "cancel": "Скасаваць", + "save": "Захаваць", + "delete": "Выдаліць", + "edit": "Рэдагаваць", + "add": "Дадаць", + "remove": "Выдаліць", + "loading": "Загрузка...", + "error": "Памылка", + "success": "Поспех", + "file": "Файл", + "files": "Файлы", + "close": "Зачыніць" + }, + "about": { + "hero": { + "title": "Мы лічым, што інструменты PDF павінны быць", + "subtitle": "хуткімі, прыватнымі і бясплатнымі.", + "noCompromises": "Без кампрамісаў." + }, + "mission": { + "title": "Наша місія", + "description": "Даць найбольш поўны набор інструментаў PDF, які шануе вашу прыватнасць і ніколі не патрабуе аплаты. Мы лічым, што неабходныя інструменты для дакументаў павінны быць даступнымі ўсім, паўсюль, без бар'ераў." + }, + "philosophy": { + "label": "Наша асноўная філасофія", + "title": "Прыватнасць на першым месцы. Заўсёды.", + "description": "У эпоху, калі даныя — гэта тавар, мы выбіраем іншы шлях. Уся апрацоўка інструментаў BentoPDF адбываецца лакальна ў вашым браўзеры. Гэта значыць, што вашы файлы ніколі не трапляюць на нашы серверы, мы не бачым вашых дакументаў і не адсочваем, што вы робіце. Вашы дакументы застаюцца цалкам і безумоўна прыватнымі. Гэта не проста функцыя — гэта наш падмурак." + }, + "whyBentopdf": { + "title": "Чаму", + "speed": { + "title": "Створаны для хуткасці", + "description": "Не трэба чакаць запампоўвання на сервер. Апрацоўваючы файлы непасрэдна ў вашым браўзеры з дапамогай сучасных вэб-тэхналогій, такіх як WebAssembly, мы прапануем непараўнальную хуткасць для ўсіх нашых інструментаў." + }, + "free": { + "title": "Цалкам бясплатна", + "description": "Без пробных версій, падпісак, схаваных плацяжоў і без \"прэміум\" функцый. Мы верым, што магутныя інструменты PDF павінны быць грамадскай карысцю, а не цэнтрам прыбытку." + }, + "noAccount": { + "title": "Не патрэбны ўліковы запіс", + "description": "Пачынайце выкарыстоўваць любы інструмент адразу. Нам не патрэбныя ваша электронная пошта, пароль або асабістыя даныя. Працоўны працэс павінен быць гладкім і ананімным." + }, + "openSource": { + "title": "Дух адкрытага кода", + "description": "Задуманы і створаны празрыстым. Мы выкарыстоўваем выдатныя бібліятэкі з адкрытым зыходным кодам, такія як PDF-lib і PDF.js, і верым у намаганні супольнасці зрабіць магутныя інструменты даступнымі ўсім." + } + }, + "cta": { + "title": "Гатовы пачаць?", + "description": "Далучайцеся да тысяч карыстальнікаў, якія давяраюць BentoPDF свае штодзённыя патрэбы з дакументамі. Адчуйце розніцу, якую даюць прыватнасць і прадукцыйнасць.", + "button": "Паглядзець усе інструменты" + } + }, + "contact": { + "title": "Звязацца з намі", + "subtitle": "Мы будзем рады пачуць ваша меркаванне. Калі ў вас ёсць пытанне, водгук або прапанова функцыі, не саромейцеся звяртацца.", + "email": "Вы можаце напісаць нам на email:" + }, + "licensing": { + "title": "Ліцэнзія на", + "subtitle": "Выберыце ліцэнзію, якая падыходзіць вам." + }, + "multiTool": { + "uploadPdfs": "Запампаваць PDF", + "upload": "Запампаваць", + "addBlankPage": "Дадаць пустую старонку", + "edit": "Рэдагаваць:", + "undo": "Адрабіць", + "redo": "Узнавіць", + "reset": "Скінуць", + "selection": "Вылучэнне:", + "selectAll": "Вылучыць усё", + "deselectAll": "Зняць вылучэнне", + "rotate": "Паварот:", + "rotateLeft": "Налева", + "rotateRight": "Направа", + "transform": "Пераўтварэнне:", + "duplicate": "Дубляваць", + "split": "Падзяліць", + "clear": "Ачысціць:", + "delete": "Выдаліць", + "download": "Спампаваць:", + "downloadSelected": "Спампаваць выбранае", + "exportPdf": "Экспартаваць PDF", + "uploadPdfFiles": "Выбраць PDF файлы", + "dragAndDrop": "Перацягніце PDF файлы сюды або націсніце, каб выбраць", + "selectFiles": "Выбраць файлы", + "renderingPages": "Апрацоўка старонак...", + "actions": { + "duplicatePage": "Дубляваць гэту старонку", + "deletePage": "Выдаліць гэту старонку", + "insertPdf": "Уставіць PDF пасля гэтай старонкі", + "toggleSplit": "Пераключыць падзел пасля гэтай старонкі" + }, + "pleaseWait": "Пачакайце", + "pagesRendering": "Старонкі яшчэ апрацоўваюцца. Пачакайце...", + "noPagesSelected": "Старонкі не выбраны", + "selectOnePage": "Выберыце хаця б адну старонку для спампоўвання.", + "noPages": "Няма старонак", + "noPagesToExport": "Няма старонак для экспарту.", + "renderingTitle": "Апрацоўка перадпраглядаў старонак", + "errorRendering": "Не ўдалося апрацаваць мініяцюры старонак", + "error": "Памылка", + "failedToLoad": "Не ўдалося загрузіць" + }, + "howItWorks": { + "title": "Як гэта працуе", + "step1": "Націсніце або перацягніце файл, каб пачаць", + "step2": "Націсніце кнопку апрацоўкі", + "step3": "Праз імгненне захавайце апрацаваны файл" + }, + "relatedTools": { + "title": "Звязаныя інструменты PDF" + }, + "simpleMode": { + "title": "Інструменты PDF", + "subtitle": "Выберыце інструмент, каб пачаць" + } +} diff --git a/public/locales/be/tools.json b/public/locales/be/tools.json new file mode 100644 index 0000000..2d959f1 --- /dev/null +++ b/public/locales/be/tools.json @@ -0,0 +1,671 @@ +{ + "categories": { + "popularTools": "Папулярныя інструменты", + "editAnnotate": "Рэдагаванне і анатацыі", + "convertToPdf": "Канвертацыя ў PDF", + "convertFromPdf": "Канвертацыя з PDF", + "organizeManage": "Арганізацыя і кіраванне", + "optimizeRepair": "Аптымізацыя і аднаўленне", + "securePdf": "Бяспека PDF" + }, + "pdfMultiTool": { + "name": "Мультыінструмент PDF", + "subtitle": "Аб'яднаць, Падзяліць, Арганізаваць, Выдаліць, Павярнуць, Дадаць пустыя старонкі, Выняць і Дубляваць у адзіным інтэрфейсе." + }, + "mergePdf": { + "name": "Аб'яднаць PDF", + "subtitle": "Аб'яднаць некалькі PDF у адзін файл. Закладкі захоўваюцца." + }, + "splitPdf": { + "name": "Падзяліць PDF", + "subtitle": "Выняць дыяпазон старонак у новы PDF." + }, + "compressPdf": { + "name": "Сціснуць PDF", + "subtitle": "Зменшыць памер файла PDF.", + "algorithmLabel": "Алгарытм сціскання", + "condense": "Condense (Рэкамендуецца)", + "photon": "Photon (Для PDF з вялікай колькасцю фота)", + "condenseInfo": "Condense выкарыстоўвае прасунутае сцісканне: выдаляе лішняе, аптымізуе відарысы, паднаборы шрыфтоў. Найлепш пасуе для большасці PDF.", + "photonInfo": "Photon ператварае старонкі ў відарысы. Для PDF з вялікай колькасцю фота або сканіраваных PDF.", + "photonWarning": "Папярэджанне: стане немагчыма вылучыць тэкст, і перастануць працаваць спасылкі.", + "levelLabel": "Узровень сціскання", + "light": "Лёгкі (Захаванне якасці)", + "balanced": "Збалансаваны (Рэкамендуецца)", + "aggressive": "Агрэсіўны (Меншыя файлы)", + "extreme": "Экстрэмальны (Максімальнае сцісканне)", + "grayscale": "Канвертаваць у градацыі шэрага", + "grayscaleHint": "Памяншае памер файла, выдаляючы інфармацыю пра колер", + "customSettings": "Карыстальніцкія налады", + "customSettingsHint": "Дакладная настройка параметраў сціскання:", + "outputQuality": "Якасць вываду", + "resizeImagesTo": "Змяніць памер відарысаў да", + "onlyProcessAbove": "Апрацоўваць толькі большыя за", + "removeMetadata": "Выдаліць метаданыя", + "subsetFonts": "Паднабор шрыфтоў (выдаліць сімвалы, якія не выкарыстоўваюцца)", + "removeThumbnails": "Выдаліць убудаваныя мініяцюры", + "compressButton": "Сціснуць PDF" + }, + "pdfEditor": { + "name": "Рэдактар PDF", + "subtitle": "Анатаваць, вылучыць, зацямніць, дадаць фігуры/відарысы, каментарыі, пошук і прагляд PDF." + }, + "jpgToPdf": { + "name": "JPG у PDF", + "subtitle": "Стварыць PDF з відарысаў JPG, JPEG і JPEG2000 (JP2/JPX)." + }, + "signPdf": { + "name": "Падпісаць PDF", + "subtitle": "Нарысаваць, набраць або запампаваць свой подпіс." + }, + "cropPdf": { + "name": "Абрэзаць PDF", + "subtitle": "Абрэзаць палі кожнай старонкі PDF." + }, + "extractPages": { + "name": "Выняць старонкі", + "subtitle": "Захаваць выбраныя старонкі ў новым файле." + }, + "duplicateOrganize": { + "name": "Дубляваць і арганізаваць", + "subtitle": "Дубляваць, змяніць парадак і выдаліць старонкі." + }, + "deletePages": { + "name": "Выдаліць старонкі", + "subtitle": "Выдаліць з дакумента пэўныя старонкі." + }, + "editBookmarks": { + "name": "Рэдагаваць закладкі", + "subtitle": "Дадаць, рэдагаваць, імпартаваць, выдаліць і выняць закладкі PDF." + }, + "tableOfContents": { + "name": "Змест", + "subtitle": "Стварыць з закладак PDF старонку зместу." + }, + "pageNumbers": { + "name": "Нумары старонак", + "subtitle": "Уставіць у дакумент нумары старонак." + }, + "batesNumbering": { + "name": "Нумарацыя Бейтса", + "subtitle": "Дадаць паслядоўныя нумары Бейтса да аднаго або некалькіх файлаў PDF." + }, + "addWatermark": { + "name": "Дадаць вадзяны знак", + "subtitle": "Накласці на старонкі PDF тэкст або відарыс.", + "applyToAllPages": "Прымяніць да ўсіх старонак" + }, + "headerFooter": { + "name": "Верхні і ніжні калантытул", + "subtitle": "Дадаць тэкст уверсе і ўнізе старонак." + }, + "invertColors": { + "name": "Інвертаваць колеры", + "subtitle": "Стварыць версію PDF у \"цёмнай тэме\"." + }, + "scannerEffect": { + "name": "Эфект сканера", + "subtitle": "Зрабіць PDF падобным да адсканаванага дакумента.", + "scanSettings": "Налады сканіравання", + "colorspace": "Каляровая прастора", + "gray": "Градацыі шэрага", + "border": "Рамка", + "rotate": "Паварот", + "rotateVariance": "Варыяцыя павароту", + "brightness": "Яркасць", + "contrast": "Кантраст", + "blur": "Размытасць", + "noise": "Шум", + "yellowish": "Жаўтаватасць", + "resolution": "Раздзяляльнасць", + "processButton": "Ужыць эфект сканера" + }, + "adjustColors": { + "name": "Наладзіць колеры", + "subtitle": "Наладзіць яркасць, кантраст, насычанасць і іншае ў PDF.", + "colorSettings": "Налады колеру", + "brightness": "Яркасць", + "contrast": "Кантраст", + "saturation": "Насычанасць", + "hueShift": "Зрух адцення", + "temperature": "Тэмпература", + "tint": "Адценне", + "gamma": "Гама", + "sepia": "Сэпія", + "processButton": "Ужыць рэгуліроўкі колеру" + }, + "backgroundColor": { + "name": "Колер фону", + "subtitle": "Змяніць колер фону PDF." + }, + "changeTextColor": { + "name": "Змяніць колер тэксту", + "subtitle": "Змяніць колер тэксту ў PDF." + }, + "addStamps": { + "name": "Дадаць штампы", + "subtitle": "Дадаць у PDF штампы-відарысы праз панэль анатацый.", + "usernameLabel": "Імя для штампа", + "usernamePlaceholder": "Увядзіце сваё імя (для штампаў)", + "usernameHint": "Гэта імя з'явіцца на створаных вамі штампах." + }, + "removeAnnotations": { + "name": "Выдаліць анатацыі", + "subtitle": "Выдаліць каментарыі, вылучэнні і спасылкі." + }, + "pdfFormFiller": { + "name": "Запоўніць форму PDF", + "subtitle": "Запоўніць форму непасрэдна ў браўзеры. Таксама падтрымліваюцца формы XFA." + }, + "createPdfForm": { + "name": "Стварыць форму PDF", + "subtitle": "Стварыць запаўняльныя формы PDF з тэкставымі палямі, якія можна перацягваць." + }, + "removeBlankPages": { + "name": "Выдаліць пустыя старонкі", + "subtitle": "Аўтаматычна выявіць і выдаліць пустыя старонкі.", + "sensitivityHint": "Вышэй = больш строга, толькі цалкам пустыя старонкі. Ніжэй = дапускае старонкі з нейкім змесцівам." + }, + "imageToPdf": { + "name": "Відарысы ў PDF", + "subtitle": "Канвертаваць JPG, PNG, BMP, GIF, TIFF, PNM, PGM, PBM, PPM, PAM, JXR, JPX, JP2, PSD, SVG, HEIC, WebP у PDF." + }, + "pngToPdf": { + "name": "PNG у PDF", + "subtitle": "Стварыць PDF з аднаго або некалькіх відарысаў PNG." + }, + "webpToPdf": { + "name": "WebP у PDF", + "subtitle": "Стварыць PDF з аднаго або некалькіх відарысаў WebP." + }, + "svgToPdf": { + "name": "SVG у PDF", + "subtitle": "Стварыць PDF з аднаго або некалькіх відарысаў SVG." + }, + "bmpToPdf": { + "name": "BMP у PDF", + "subtitle": "Стварыць PDF з аднаго або некалькіх відарысаў BMP." + }, + "heicToPdf": { + "name": "HEIC у PDF", + "subtitle": "Стварыць PDF з аднаго або некалькіх відарысаў HEIC." + }, + "tiffToPdf": { + "name": "TIFF у PDF", + "subtitle": "Стварыць PDF з аднаго або некалькіх відарысаў TIFF." + }, + "textToPdf": { + "name": "Тэкст у PDF", + "subtitle": "Канвертаваць звычайны тэкставы файл у PDF." + }, + "jsonToPdf": { + "name": "JSON у PDF", + "subtitle": "Канвертаваць файлы JSON у фармат PDF." + }, + "pdfToJpg": { + "name": "PDF у JPG", + "subtitle": "Канвертаваць кожную старонку PDF у відарыс JPG." + }, + "pdfToPng": { + "name": "PDF у PNG", + "subtitle": "Канвертаваць кожную старонку PDF у відарыс PNG." + }, + "pdfToWebp": { + "name": "PDF у WebP", + "subtitle": "Канвертаваць кожную старонку PDF у відарыс WebP." + }, + "pdfToBmp": { + "name": "PDF у BMP", + "subtitle": "Канвертаваць кожную старонку PDF у відарыс BMP." + }, + "pdfToTiff": { + "name": "PDF у TIFF", + "subtitle": "Канвертаваць кожную старонку PDF у відарыс TIFF." + }, + "pdfToGreyscale": { + "name": "PDF у градацыі шэрага", + "subtitle": "Канвертаваць усе колеры ў чорна-белыя." + }, + "pdfToJson": { + "name": "PDF у JSON", + "subtitle": "Канвертаваць файлы PDF у фармат JSON." + }, + "ocrPdf": { + "name": "OCR PDF", + "subtitle": "Дадаць у PDF магчымасці пошуку і капіравання." + }, + "alternateMix": { + "name": "Чаргаваць і змяшаць старонкі", + "subtitle": "Аб'яднаць PDF, чаргуючы старонкі з асобных PDF. Закладкі захоўваюцца." + }, + "addAttachments": { + "name": "Дадаць далучэнні", + "subtitle": "Убудаваць адзін або некалькі файлаў у PDF." + }, + "extractAttachments": { + "name": "Выняць далучэнні", + "subtitle": "Выняць усе ўбудаваныя файлы з PDF як ZIP." + }, + "editAttachments": { + "name": "Рэдагаваць далучэнні", + "subtitle": "Прагледзець або выдаліць далучэнні ў PDF." + }, + "dividePages": { + "name": "Падзяліць старонкі", + "subtitle": "Падзяліць старонкі гарызантальна або вертыкальна." + }, + "addBlankPage": { + "name": "Дадаць пустую старонку", + "subtitle": "Уставіць пустую старонку ў любым месцы PDF." + }, + "reversePages": { + "name": "Адваротны парадак старонак", + "subtitle": "Змяніць парадак усіх старонак у дакуменце на адваротны." + }, + "rotatePdf": { + "name": "Павярнуць PDF", + "subtitle": "Павярнуць старонкі на 90 градусаў." + }, + "rotateCustom": { + "name": "Павярнуць на зададзены вугал", + "subtitle": "Павярнуць старонкі на любы вугал." + }, + "nUpPdf": { + "name": "N-Up PDF", + "subtitle": "Размясціць некалькі старонак на адным аркушы." + }, + "combineToSinglePage": { + "name": "Аб'яднаць у адну старонку", + "subtitle": "Сшыць усе старонкі ў адну бесперапынную прагортку." + }, + "viewMetadata": { + "name": "Праглядзець метаданыя", + "subtitle": "Праглядзець схаваныя ўласцівасці PDF." + }, + "editMetadata": { + "name": "Рэдагаваць метаданыя", + "subtitle": "Змяніць аўтара, назву і іншыя ўласцівасці." + }, + "pdfsToZip": { + "name": "PDF у ZIP", + "subtitle": "Запакаваць некалькі файлаў PDF у ZIP-архіў." + }, + "comparePdfs": { + "name": "Параўнаць PDF", + "subtitle": "Параўнаць два PDF побач.", + "firstPdf": "Першы PDF", + "secondPdf": "Другі PDF", + "clickOrDrop": "Націсніце або перацягніце", + "page": "Старонка", + "overlay": "Накладанне", + "sideBySide": "Побач", + "flicker": "Мігценне", + "syncScroll": "Сінхранізаваць пракрутку", + "export": "Экспарт", + "exportAsPdf": "Экспартаваць як PDF", + "splitView": "Падзелены выгляд", + "alternating": "Чаргаванне", + "leftDocument": "Левы дакумент", + "rightDocument": "Правы дакумент", + "original": "Арыгінал", + "modified": "Зменены", + "searchChanges": "Шукаць змены...", + "deleted": "Выдалена", + "added": "Дададзена", + "prevPage": "Папярэдняя старонка", + "nextPage": "Наступная старонка", + "prevChange": "Папярэдняя змена", + "nextChange": "Наступная змена", + "uploadTwoPdfs": "Загрузіце два PDF, каб убачыць адрозненні.", + "noDifferences": "На гэтай старонцы адрозненняў не выяўлена.", + "noMatchingChanges": "Няма змен, што адпавядаюць бягучаму фільтру.", + "pageNotExist": "Старонка {{page}} не існуе ў гэтым PDF.", + "noPairedPage": "Для гэтага боку няма спаранай старонкі.", + "buildingModel": "Стварэнне мадэлі супастаўлення старонак...", + "indexingPdf": "Індэксацыя PDF {{num}}, старонка {{page}} з {{total}}...", + "loadingComparison": "Загрузка параўнання {{current}} з {{total}}...", + "runningOcr": "Запуск OCR на старонцы {{page}}...", + "preparingExport": "Падрыхтоўка экспарту PDF...", + "renderingPage": "Адмалёўка старонкі {{current}} з {{total}}...", + "exportError": "Памылка экспарту", + "exportFailed": "Не ўдалося экспартаваць PDF параўнання.", + "loadingFile": "Загрузка {{name}}...", + "invalidFile": "Няправільны файл", + "invalidFileMsg": "Калі ласка, абярыце сапраўдны PDF-файл.", + "loadError": "Не ўдалося загрузіць PDF. Магчыма, ён пашкоджаны або абаронены паролем." + }, + "posterizePdf": { + "name": "Пераўтварыць у постэр", + "subtitle": "Разбіць вялікую старонку на некалькі меншых." + }, + "fixPageSize": { + "name": "Уніфікаваць памер старонак", + "subtitle": "Уніфікаваць памер усіх старонак." + }, + "linearizePdf": { + "name": "Зрабіць лінейны PDF", + "subtitle": "Аптымізаваць PDF для хуткага прагляду ў інтэрнэце." + }, + "pageDimensions": { + "name": "Памеры старонкі", + "subtitle": "Аналізаваць памер старонкі, арыентацыю і адзінкі." + }, + "removeRestrictions": { + "name": "Выдаліць абмежаванні", + "subtitle": "Выдаліць ахову паролем і абмежаванні бяспекі, звязаныя з лічбавымі подпісамі PDF." + }, + "repairPdf": { + "name": "Аднавіць PDF", + "subtitle": "Аднавіць даныя з пашкоджаных файлаў PDF." + }, + "encryptPdf": { + "name": "Зашыфраваць PDF", + "subtitle": "Заблакіраваць PDF, дадаўшы пароль." + }, + "sanitizePdf": { + "name": "Ачысціць PDF", + "subtitle": "Выдаліць метаданыя, анатацыі, скрыпты і іншае." + }, + "decryptPdf": { + "name": "Расшыфраваць PDF", + "subtitle": "Разблакіраваць PDF, выдаліўшы пароль." + }, + "flattenPdf": { + "name": "Звесці PDF", + "subtitle": "Зрабіць палі формы і анатацыі толькі для чытання." + }, + "removeMetadata": { + "name": "Выдаліць метаданыя", + "subtitle": "Выдаліць схаваныя даныя з PDF." + }, + "changePermissions": { + "name": "Змяніць дазволы", + "subtitle": "Задаць або змяніць правы карыстальнікаў для PDF." + }, + "odtToPdf": { + "name": "ODT у PDF", + "subtitle": "Канвертаваць файлы OpenDocument Text у PDF. Падтрымлівае некалькі файлаў.", + "acceptedFormats": "файлы ODT", + "convertButton": "Канвертаваць у PDF" + }, + "csvToPdf": { + "name": "CSV у PDF", + "subtitle": "Канвертаваць табліцы CSV у PDF. Падтрымлівае некалькі файлаў.", + "acceptedFormats": "файлы CSV", + "convertButton": "Канвертаваць у PDF" + }, + "rtfToPdf": { + "name": "RTF у PDF", + "subtitle": "Канвертаваць дакументы Rich Text Format у PDF. Падтрымлівае некалькі файлаў.", + "acceptedFormats": "файлы RTF", + "convertButton": "Канвертаваць у PDF" + }, + "wordToPdf": { + "name": "Word у PDF", + "subtitle": "Канвертаваць дакументы Word (DOCX, DOC, ODT, RTF) у PDF. Падтрымлівае некалькі файлаў.", + "acceptedFormats": "файлы DOCX, DOC, ODT, RTF", + "convertButton": "Канвертаваць у PDF" + }, + "excelToPdf": { + "name": "Excel у PDF", + "subtitle": "Канвертаваць табліцы Excel (XLSX, XLS, ODS, CSV) у PDF. Падтрымлівае некалькі файлаў.", + "acceptedFormats": "файлы XLSX, XLS, ODS, CSV", + "convertButton": "Канвертаваць у PDF" + }, + "powerpointToPdf": { + "name": "PowerPoint у PDF", + "subtitle": "Канвертаваць прэзентацыі PowerPoint (PPTX, PPT, ODP) у PDF. Падтрымлівае некалькі файлаў.", + "acceptedFormats": "файлы PPTX, PPT, ODP", + "convertButton": "Канвертаваць у PDF" + }, + "markdownToPdf": { + "name": "Markdown у PDF", + "subtitle": "Напісаць або ўставіць Markdown і экспартаваць яго ў прыгожа аформлены PDF.", + "paneMarkdown": "Markdown", + "panePreview": "Папярэдні прагляд", + "btnUpload": "Запампаваць", + "btnSyncScroll": "Сінхранізацыя пракруткі", + "btnSettings": "Налады", + "btnExportPdf": "Экспартаваць PDF", + "settingsTitle": "Налады Markdown", + "settingsPreset": "Набор налад", + "presetDefault": "Прадвызначаны (як GFM)", + "presetCommonmark": "CommonMark (строгі)", + "presetZero": "Мінімальны (без функцый)", + "settingsOptions": "Параметры Markdown", + "optAllowHtml": "Дазволіць HTML-тэгі", + "optBreaks": "Пераўтвараць пераносы радкоў у
", + "optLinkify": "Аўтаматычна пераўтвараць URL-адрасы ў спасылкі", + "optTypographer": "Тыпограф (разумныя двукоссі і інш.)" + }, + "pdfBooklet": { + "name": "Буклет PDF", + "subtitle": "Змяніць парадак старонак для друку двухбаковага буклета. Складзіце і счапіце аркушы, каб атрымаўся буклет.", + "howItWorks": "Як гэта працуе:", + "step1": "Запампуйце файл PDF.", + "step2": "Парадак старонак будзе зменены для буклета.", + "step3": "Надрукуйце з двух бакоў, перавярніце па кароткім краі, складзіце і сшыйце.", + "paperSize": "Памер паперы", + "orientation": "Арыентацыя", + "portrait": "Кніжная", + "landscape": "Альбомная", + "pagesPerSheet": "Старонак на аркуш", + "createBooklet": "Стварыць буклет", + "processing": "Апрацоўка...", + "pageCount": "Колькасць старонак будзе дапоўнена да кратнай 4 пры неабходнасці." + }, + "xpsToPdf": { + "name": "XPS у PDF", + "subtitle": "Канвертаваць дакументы XPS/OXPS у PDF. Падтрымлівае некалькі файлаў.", + "acceptedFormats": "файлы XPS, OXPS", + "convertButton": "Канвертаваць у PDF" + }, + "mobiToPdf": { + "name": "MOBI у PDF", + "subtitle": "Канвертаваць электронныя кнігі MOBI у PDF. Падтрымлівае некалькі файлаў.", + "acceptedFormats": "файлы MOBI", + "convertButton": "Канвертаваць у PDF" + }, + "epubToPdf": { + "name": "EPUB у PDF", + "subtitle": "Канвертаваць электронныя кнігі EPUB у PDF. Падтрымлівае некалькі файлаў.", + "acceptedFormats": "файлы EPUB", + "convertButton": "Канвертаваць у PDF" + }, + "fb2ToPdf": { + "name": "FB2 у PDF", + "subtitle": "Канвертаваць электронныя кнігі FictionBook (FB2) у PDF. Падтрымлівае некалькі файлаў.", + "acceptedFormats": "файлы FB2", + "convertButton": "Канвертаваць у PDF" + }, + "cbzToPdf": { + "name": "CBZ у PDF", + "subtitle": "Канвертаваць архівы коміксаў (CBZ/CBR) у PDF. Падтрымлівае некалькі файлаў.", + "acceptedFormats": "файлы CBZ, CBR", + "convertButton": "Канвертаваць у PDF" + }, + "wpdToPdf": { + "name": "WPD у PDF", + "subtitle": "Канвертаваць дакументы WordPerfect (WPD) у PDF. Падтрымлівае некалькі файлаў.", + "acceptedFormats": "файлы WPD", + "convertButton": "Канвертаваць у PDF" + }, + "wpsToPdf": { + "name": "WPS у PDF", + "subtitle": "Канвертаваць дакументы WPS Office у PDF. Падтрымлівае некалькі файлаў.", + "acceptedFormats": "файлы WPS", + "convertButton": "Канвертаваць у PDF" + }, + "xmlToPdf": { + "name": "XML у PDF", + "subtitle": "Канвертаваць дакументы XML у PDF. Падтрымлівае некалькі файлаў.", + "acceptedFormats": "файлы XML", + "convertButton": "Канвертаваць у PDF" + }, + "pagesToPdf": { + "name": "Pages у PDF", + "subtitle": "Канвертаваць дакументы Apple Pages у PDF. Падтрымлівае некалькі файлаў.", + "acceptedFormats": "файлы Pages", + "convertButton": "Канвертаваць у PDF" + }, + "odgToPdf": { + "name": "ODG у PDF", + "subtitle": "Канвертаваць файлы OpenDocument Graphics (ODG) у PDF. Падтрымлівае некалькі файлаў.", + "acceptedFormats": "файлы ODG", + "convertButton": "Канвертаваць у PDF" + }, + "odsToPdf": { + "name": "ODS у PDF", + "subtitle": "Канвертаваць файлы OpenDocument Spreadsheet (ODS) у PDF. Падтрымлівае некалькі файлаў.", + "acceptedFormats": "файлы ODS", + "convertButton": "Канвертаваць у PDF" + }, + "odpToPdf": { + "name": "ODP у PDF", + "subtitle": "Канвертаваць файлы OpenDocument Presentation (ODP) у PDF. Падтрымлівае некалькі файлаў.", + "acceptedFormats": "файлы ODP", + "convertButton": "Канвертаваць у PDF" + }, + "pubToPdf": { + "name": "PUB у PDF", + "subtitle": "Канвертаваць файлы Microsoft Publisher (PUB) у PDF. Падтрымлівае некалькі файлаў.", + "acceptedFormats": "файлы PUB", + "convertButton": "Канвертаваць у PDF" + }, + "vsdToPdf": { + "name": "VSD у PDF", + "subtitle": "Канвертаваць файлы Microsoft Visio (VSD, VSDX) у PDF. Падтрымлівае некалькі файлаў.", + "acceptedFormats": "файлы VSD, VSDX", + "convertButton": "Канвертаваць у PDF" + }, + "psdToPdf": { + "name": "PSD у PDF", + "subtitle": "Канвертаваць файлы Adobe Photoshop (PSD) у PDF. Падтрымлівае некалькі файлаў.", + "acceptedFormats": "файлы PSD", + "convertButton": "Канвертаваць у PDF" + }, + "pdfToSvg": { + "name": "PDF у SVG", + "subtitle": "Канвертаваць кожную старонку PDF у маштабаваную вектарную графіку (SVG) для ідэальнай якасці на любым памеры." + }, + "extractTables": { + "name": "Выняць табліцы з PDF", + "subtitle": "Выняць табліцы з PDF і экспартаваць у CSV, JSON або Markdown." + }, + "pdfToCsv": { + "name": "PDF у CSV", + "subtitle": "Выняць табліцы з PDF і канвертаваць у CSV." + }, + "pdfToExcel": { + "name": "PDF у Excel", + "subtitle": "Выняць табліцы з PDF і канвертаваць у Excel (XLSX)." + }, + "pdfToText": { + "name": "PDF у тэкст", + "subtitle": "Выняць тэкст з файлаў PDF і захаваць як тэкставы файл (.txt). Падтрымлівае некалькі файлаў.", + "note": "Гэты інструмент працуе ТОЛЬКІ з PDF, створанымі лічбавым спосабам. Для сканіраваных дакументаў або PDF на аснове відарысаў выкарыстоўвайце інструмент OCR PDF.", + "convertButton": "Выняць тэкст" + }, + "digitalSignPdf": { + "name": "Лічбавы подпіс PDF", + "pageTitle": "Лічбавы подпіс PDF - Дадаць крыптаграфічны подпіс | BentoPDF", + "subtitle": "Дадаць крыптаграфічны лічбавы подпіс да PDF з выкарыстаннем сертыфікатаў X.509. Падтрымлівае фарматы PKCS#12 (.pfx, .p12) і PEM. Ваш прыватны ключ ніколі не пакідае ваш браўзер.", + "certificateSection": "Сертыфікат", + "uploadCert": "Запампаваць сертыфікат (.pfx, .p12)", + "certPassword": "Пароль сертыфіката", + "certPasswordPlaceholder": "Увядзіце пароль сертыфіката", + "certInfo": "Інфармацыя пра сертыфікат", + "certSubject": "Суб'ект", + "certIssuer": "Выдавец", + "certValidity": "Дзейсны", + "signatureDetails": "Дэталі подпісу (неабавязкова)", + "reason": "Прычына", + "reasonPlaceholder": "напрыклад, я ўхваляю гэты дакумент", + "location": "Месцазнаходжанне", + "locationPlaceholder": "напрыклад, Мінск, Беларусь", + "contactInfo": "Кантактная інфармацыя", + "contactPlaceholder": "напрыклад, email@example.com", + "applySignature": "Ужыць лічбавы подпіс", + "successMessage": "PDF паспяхова падпісаны! Подпіс можна праверыць у любым PDF-чытальніку." + }, + "validateSignaturePdf": { + "name": "Праверыць подпіс PDF", + "pageTitle": "Праверыць подпіс PDF - Верыфікацыя лічбавых подпісаў | BentoPDF", + "subtitle": "Праверыць лічбавыя подпісы ў PDF. Праверыць сапраўднасць сертыфіката, паглядзець дэталі падпісанта і пацвердзіць надзейнасць дакумента. Уся апрацоўка адбываецца ў вашым браўзеры." + }, + "emailToPdf": { + "name": "Email у PDF", + "subtitle": "Канвертаваць файлы email (EML, MSG) у PDF. Падтрымлівае экспарты Outlook і стандартныя email-фарматы.", + "acceptedFormats": "файлы EML, MSG", + "convertButton": "Канвертаваць у PDF" + }, + "fontToOutline": { + "name": "Шрыфт у контуры", + "subtitle": "Ператварыць усе шрыфты ў вектарныя контуры для стабільнага адлюстравання на ўсіх прыладах." + }, + "deskewPdf": { + "name": "Выпрастаць PDF", + "subtitle": "Аўтаматычна выраўняць нахіленыя адсканіраваныя старонкі з дапамогай OpenCV." + }, + "pdfToWord": { + "name": "PDF у Word", + "subtitle": "Канвертаваць файлы PDF у дакументы Word, якія можна рэдагаваць." + }, + "extractImages": { + "name": "Выняць відарысы", + "subtitle": "Выняць усе ўбудаваныя відарысы з файлаў PDF." + }, + "pdfToMarkdown": { + "name": "PDF у Markdown", + "subtitle": "Канвертаваць тэкст і табліцы PDF у фармат Markdown." + }, + "preparePdfForAi": { + "name": "Падрыхтаваць PDF для ШI", + "subtitle": "Выняць змесціва PDF як JSON LlamaIndex для канвеераў RAG/LLM." + }, + "pdfOcg": { + "name": "PDF OCG", + "subtitle": "Праглядзець, пераключыць, дадаць і выдаліць слаі OCG у PDF." + }, + "pdfToPdfa": { + "name": "PDF у PDF/A", + "subtitle": "Канвертаваць PDF у PDF/A для доўгатэрміновага архівавання." + }, + "rasterizePdf": { + "name": "Растарызаваць PDF", + "subtitle": "Канвертаваць PDF у PDF на аснове відарысаў. Звесці слаі і выдаліць тэкст, які можна вылучыць." + }, + "pdfWorkflow": { + "name": "Канструктар рабочага працэсу PDF", + "subtitle": "Стварыць уласныя канвееры апрацоўкі PDF з дапамогай візуальнага рэдактара.", + "nodes": "Вузлы", + "searchNodes": "Пошук вузлоў...", + "run": "Запусціць", + "clear": "Ачысціць", + "save": "Захаваць", + "load": "Загрузіць", + "export": "Экспартаваць", + "import": "Імпартаваць", + "ready": "Гатова", + "settings": "Налады", + "processing": "Апрацоўка...", + "saveTemplate": "Захаваць шаблон", + "templateName": "Назва шаблону", + "templatePlaceholder": "напрыклад, Праца з накладнымі", + "cancel": "Скасаваць", + "loadTemplate": "Загрузіць шаблон", + "noTemplates": "Пакуль няма захаваных шаблонаў.", + "ok": "OK", + "workflowCompleted": "Рабочы працэс завершаны", + "errorDuringExecution": "Памылка падчас выканання", + "addNodeError": "Дадайце хаця б адзін вузел для запуску рабочага працэсу.", + "needInputOutput": "Для працы рабочага працэсу патрэбен хаця б адзін вузел уводу і адзін вузел вываду.", + "enterName": "Увядзіце назву.", + "templateExists": "Шаблон з такой назвай ужо існуе.", + "templateSaved": "Шаблон \"{{name}}\" захаваны.", + "templateLoaded": "Шаблон \"{{name}}\" загружаны.", + "failedLoadTemplate": "Не ўдалося загрузіць шаблон.", + "noSettings": "Няма налад для гэтага вузла.", + "advancedSettings": "Пашыраныя налады" + } +} diff --git a/public/locales/da/common.json b/public/locales/da/common.json new file mode 100644 index 0000000..6710815 --- /dev/null +++ b/public/locales/da/common.json @@ -0,0 +1,365 @@ +{ + "nav": { + "home": "Hjem", + "about": "Om", + "contact": "Kontakt", + "licensing": "licensing", + "allTools": "Alle værktøjer", + "openMainMenu": "Åbn hovedmenu", + "language": "Sprog" + }, + "donation": { + "message": "Elsker du BentoPDF? Hjælp os med at holde det gratis og open source!", + "button": "Donér" + }, + "hero": { + "title": "Den", + "pdfToolkit": "PDF-værktøjskasse", + "builtForPrivacy": "bygget til privatliv", + "noSignups": "Ingen tilmeldinger", + "unlimitedUse": "Ubegrænset brug", + "worksOffline": "Virker offline", + "startUsing": "Start med at bruge det nu" + }, + "usedBy": { + "title": "Bruges af virksomheder og personer der arbejder hos" + }, + "features": { + "title": "Hvorfor vælge", + "bentoPdf": "BentoPDF?", + "noSignup": { + "title": "Ingen tilmelding", + "description": "Start med det samme, ingen konti eller e-mails." + }, + "noUploads": { + "title": "Ingen uploads", + "description": "100% klientside – dine filer forlader aldrig din enhed." + }, + "foreverFree": { + "title": "Altid gratis", + "description": "Alle værktøjer, ingen prøveperioder, ingen betalingsmure." + }, + "noLimits": { + "title": "Ingen begrænsninger", + "description": "Brug det så meget du vil, ingen skjulte grænser." + }, + "batchProcessing": { + "title": "Batchbehandling", + "description": "Håndter ubegrænsede PDF'er på én gang." + }, + "lightningFast": { + "title": "Lynhurtigt", + "description": "Behandl PDF’er øjeblikkeligt, uden ventetid eller forsinkelser." + } + }, + "tools": { + "title": "Kom i gang med", + "toolsLabel": "Værktøjer", + "subtitle": "Klik på et værktøj for at åbne fil-upload", + "searchPlaceholder": "Søg efter et værktøj (f.eks. 'split', 'organiser'...)", + "backToTools": "Tilbage til værktøjer", + "firstLoadNotice": "Første indlæsning tager et øjeblik, mens vi downloader konverteringsmotoren. Derefter vil alt indlæses øjeblikkeligt." + }, + "upload": { + "clickToSelect": "Klik for at vælge en fil", + "orDragAndDrop": "eller træk og slip", + "pdfOrImages": "PDF’er eller billeder", + "filesNeverLeave": "Dine filer forlader aldrig din enhed.", + "addMore": "Tilføj flere filer", + "clearAll": "Ryd alle", + "clearFiles": "Ryd filer", + "hints": { + "singlePdf": "En enkelt PDF-fil", + "pdfFile": "PDF-fil", + "multiplePdfs2": "Flere PDF-filer (mindst 2)", + "bmpImages": "BMP-billeder", + "oneOrMorePdfs": "En eller flere PDF-filer", + "pdfDocuments": "PDF-dokumenter", + "oneOrMoreCsv": "En eller flere CSV-filer", + "multiplePdfsSupported": "Flere PDF-filer understøttet", + "singleOrMultiplePdfs": "Enkelt eller flere PDF-filer understøttet", + "singlePdfFile": "Enkelt PDF-fil", + "pdfWithForms": "PDF-fil med formularfelter", + "heicImages": "HEIC/HEIF-billeder", + "jpgImages": "JPG, JPEG, JP2, JPX-billeder", + "pdfsOrImages": "PDF’er eller billeder", + "oneOrMoreOdt": "En eller flere ODT-filer", + "singlePdfOnly": "Kun én PDF-fil", + "pdfFiles": "PDF-filer", + "multiplePdfs": "Flere PDF-filer", + "pngImages": "PNG-billeder", + "pdfFilesOneOrMore": "PDF-filer (en eller flere)", + "oneOrMoreRtf": "En eller flere RTF-filer", + "svgGraphics": "SVG-grafik", + "tiffImages": "TIFF-billeder", + "webpImages": "WebP-billeder" + } + }, + "howItWorks": { + "title": "Sådan fungerer det", + "step1": "Klik eller træk og slip din fil for at starte", + "step2": "Klik på behandl-knappen for at starte", + "step3": "Gem din behandlede fil med det samme" + }, + "relatedTools": { + "title": "Relaterede PDF-værktøjer" + }, + "loader": { + "processing": "Behandler..." + }, + "alert": { + "title": "Advarsel", + "ok": "OK" + }, + "preview": { + "title": "Dokumentforhåndsvisning", + "downloadAsPdf": "Download som PDF", + "close": "Luk" + }, + "settings": { + "title": "Indstillinger", + "shortcuts": "Genveje", + "preferences": "Præferencer", + "displayPreferences": "Visningspræferencer", + "searchShortcuts": "Søg efter genveje...", + "shortcutsInfo": "Tryk og hold taster nede for at sætte en genvej. Ændringer gemmes automatisk.", + "shortcutsWarning": "⚠️ Undgå almindelige browsergenveje (Cmd/Ctrl+W, Cmd/Ctrl+T, Cmd/Ctrl+N osv.), da de muligvis ikke fungerer stabilt.", + "import": "Importér", + "export": "Eksportér", + "resetToDefaults": "Nulstil til standard", + "fullWidthMode": "Fuld bredde-tilstand", + "fullWidthDescription": "Brug hele skærmbredden til alle værktøjer i stedet for en centreret container", + "settingsAutoSaved": "Indstillinger gemmes automatisk", + "clickToSet": "Klik for at vælge", + "pressKeys": "Tryk på taster...", + "warnings": { + "alreadyInUse": "Genvej bruges allerede", + "assignedTo": "er allerede tildelt:", + "chooseDifferent": "Vælg venligst en anden genvej.", + "reserved": "Advarsel om reserveret genvej", + "commonlyUsed": "bruges ofte til:", + "unreliable": "Denne genvej fungerer muligvis ikke stabilt eller kan konfliktere med browser/system.", + "useAnyway": "Vil du bruge den alligevel?", + "resetTitle": "Nulstil genveje", + "resetMessage": "Er du sikker på, at du vil nulstille alle genveje til standard?

Denne handling kan ikke fortrydes.", + "importSuccessTitle": "Import gennemført", + "importSuccessMessage": "Genveje importeret!", + "importFailTitle": "Import mislykkedes", + "importFailMessage": "Kunne ikke importere genveje. Ugyldigt filformat." + } + }, + "warning": { + "title": "Advarsel", + "cancel": "Annuller", + "proceed": "Fortsæt" + }, + "compliance": { + "title": "Dine data forlader aldrig din enhed", + "weKeep": "Vi holder", + "yourInfoSafe": "dine oplysninger sikre", + "byFollowingStandards": "ved at følge globale sikkerhedsstandarder.", + "processingLocal": "Al behandling foregår lokalt på din enhed.", + "gdpr": { + "title": "GDPR-overholdelse", + "description": "Beskytter persondata og privatliv for personer i EU." + }, + "ccpa": { + "title": "CCPA-overholdelse", + "description": "Giver Californiens borgere rettigheder over deres personlige oplysninger." + }, + "hipaa": { + "title": "HIPAA-overholdelse", + "description": "Fastlægger krav til håndtering af følsomme sundhedsoplysninger i USA." + } + }, + "faq": { + "title": "Ofte stillede", + "questions": "Spørgsmål", + "sectionTitle": "Ofte stillede spørgsmål", + "isFree": { + "question": "Er BentoPDF virkelig gratis?", + "answer": "Ja, absolut. Alle værktøjer er 100% gratis at bruge, uden filgrænser, uden tilmeldinger og uden vandmærker." + }, + "areFilesSecure": { + "question": "Er mine filer sikre? Hvor bliver de behandlet?", + "answer": "Dine filer er så sikre som muligt, fordi de aldrig forlader din computer. Alt behandles direkte i din browser." + }, + "platforms": { + "question": "Virker det på Mac, Windows og mobil?", + "answer": "Ja! BentoPDF virker på alle moderne browsere, uanset styresystem." + }, + "gdprCompliant": { + "question": "Er BentoPDF GDPR-kompatibel?", + "answer": "Ja. Da vi ikke indsamler eller behandler dine filer på vores servere, er dine data altid under din kontrol." + }, + "dataStorage": { + "question": "Gemmer eller sporer I mine filer?", + "answer": "Nej. Vi gemmer eller sporer aldrig dine filer. Alt foregår i din browser." + }, + "different": { + "question": "Hvad gør BentoPDF anderledes?", + "answer": "De fleste PDF-værktøjer uploader dine filer til en server. BentoPDF gør det hele lokalt i din browser." + }, + "browserBased": { + "question": "Hvordan gør browserbaseret behandling mig sikker?", + "answer": "Dine filer forlader aldrig enheden, hvilket fjerner risikoen for datalæk, hacks eller uautoriseret adgang." + }, + "analytics": { + "question": "Bruger I cookies eller analyseværktøjer?", + "answer": "Vi bruger kun Simple Analytics til anonyme besøgsdata. Ingen personlige oplysninger indsamles." + } + }, + "testimonials": { + "title": "Hvad vores", + "users": "Brugere", + "say": "Siger" + }, + "support": { + "title": "Kan du lide mit arbejde?", + "description": "BentoPDF er et passioneret projekt, bygget for at tilbyde et gratis og privat PDF-værktøj til alle.", + "buyMeCoffee": "Køb en kaffe til mig" + }, + "footer": { + "copyright": "© 2026 BentoPDF. Alle rettigheder forbeholdes.", + "version": "Version", + "company": "Virksomhed", + "aboutUs": "Om os", + "faqLink": "FAQ", + "contactUs": "Kontakt os", + "legal": "Juridisk", + "termsAndConditions": "Terms and Conditions", + "privacyPolicy": "Privacy Policy", + "followUs": "Følg os" + }, + "merge": { + "title": "Flet PDF'er", + "description": "Kombinér hele filer eller vælg specifikke sider til et nyt dokument.", + "fileMode": "Filtilstand", + "pageMode": "Sidetilstand", + "howItWorks": "Sådan fungerer det:", + "fileModeInstructions": [ + "Klik og træk ikonet for at ændre rækkefølge.", + "I \"Sider\"-feltet kan du angive intervaller (fx \"1-3, 5\").", + "Lad feltet stå tomt for at inkludere alle sider." + ], + "pageModeInstructions": [ + "Alle sider fra dine PDF’er vises nedenfor.", + "Træk og slip siderne for at lave den ønskede rækkefølge." + ], + "mergePdfs": "Flet PDF'er" + }, + "common": { + "page": "Side", + "pages": "Sider", + "of": "af", + "download": "Download", + "cancel": "Annuller", + "save": "Gem", + "delete": "Slet", + "edit": "Rediger", + "add": "Tilføj", + "remove": "Fjern", + "loading": "Indlæser...", + "error": "Fejl", + "success": "Succes", + "file": "Fil", + "files": "Filer", + "close": "Luk" + }, + "about": { + "hero": { + "title": "Vi mener PDF-værktøjer bør være", + "subtitle": "hurtige, private og gratis.", + "noCompromises": "Ingen kompromiser." + }, + "mission": { + "title": "Vores mission", + "description": "At give den mest komplette PDF-værktøjskasse uden betaling og med fuldt fokus på privatliv." + }, + "philosophy": { + "label": "Vores kernefilosofi", + "title": "Privatliv først. Altid.", + "description": "Alt sker lokalt i din browser. Dine dokumenter er 100% private." + }, + "whyBentopdf": { + "title": "Hvorfor", + "speed": { + "title": "Bygget til hastighed", + "description": "Ingen ventetid på uploads eller downloads — alt behandles lokalt." + }, + "free": { + "title": "Fuldstændig gratis", + "description": "Ingen abonnementer, ingen skjulte gebyrer, ingen premiumlås." + }, + "noAccount": { + "title": "Ingen konto nødvendig", + "description": "Brug værktøjerne med det samme — helt uden login." + }, + "openSource": { + "title": "Open source-ånd", + "description": "Bygget med gennemsigtighed og baseret på stærke open source-biblioteker." + } + }, + "cta": { + "title": "Klar til at komme i gang?", + "description": "Prøv selv den hurtige og private PDF-oplevelse.", + "button": "Udforsk alle værktøjer" + } + }, + "contact": { + "title": "Kontakt os", + "subtitle": "Vi vil gerne høre fra dig — spørgsmål, feedback eller ønsker er velkomne.", + "email": "Du kan kontakte os direkte på:" + }, + "licensing": { + "title": "licensing til", + "subtitle": "Vælg den licens der passer til dine behov." + }, + "multiTool": { + "uploadPdfs": "Upload PDF'er", + "upload": "Upload", + "addBlankPage": "Tilføj tom side", + "edit": "Rediger:", + "undo": "Fortryd", + "redo": "Gentag", + "reset": "Nulstil", + "selection": "Markering:", + "selectAll": "Vælg alle", + "deselectAll": "Fravælg alle", + "rotate": "Rotér:", + "rotateLeft": "Venstre", + "rotateRight": "Højre", + "transform": "Transformér:", + "duplicate": "Duplikér", + "split": "Opdel", + "clear": "Ryd:", + "delete": "Slet", + "download": "Download:", + "downloadSelected": "Download valgte", + "exportPdf": "Eksportér PDF", + "uploadPdfFiles": "Vælg PDF-filer", + "dragAndDrop": "Træk og slip PDF-filer her, eller klik for at vælge", + "selectFiles": "Vælg filer", + "renderingPages": "Renderer sider...", + "actions": { + "duplicatePage": "Duplikér denne side", + "deletePage": "Slet denne side", + "insertPdf": "Indsæt PDF efter denne side", + "toggleSplit": "Slå opdeling til/fra efter denne side" + }, + "pleaseWait": "Vent venligst", + "pagesRendering": "Siderne bliver stadig renderet. Vent venligst...", + "noPagesSelected": "Ingen sider valgt", + "selectOnePage": "Vælg mindst én side for at downloade.", + "noPages": "Ingen sider", + "noPagesToExport": "Der er ingen sider at eksportere.", + "renderingTitle": "Renderer side-forhåndsvisninger", + "errorRendering": "Kunne ikke rendere side-miniaturer", + "error": "Fejl", + "failedToLoad": "Kunne ikke indlæses" + }, + "simpleMode": { + "title": "PDF-værktøjer", + "subtitle": "Vælg et værktøj for at komme i gang" + } +} diff --git a/public/locales/da/tools.json b/public/locales/da/tools.json new file mode 100644 index 0000000..df4c677 --- /dev/null +++ b/public/locales/da/tools.json @@ -0,0 +1,671 @@ +{ + "categories": { + "popularTools": "Populære værktøjer", + "editAnnotate": "Rediger og annotér", + "convertToPdf": "Konverter til PDF", + "convertFromPdf": "Konverter fra PDF", + "organizeManage": "Organisér og administrér", + "optimizeRepair": "Optimer og reparér", + "securePdf": "Sikre PDF" + }, + "pdfMultiTool": { + "name": "PDF Multi-værktøj", + "subtitle": "Flet, opdel, organisér, slet, roter, tilføj tomme sider, udtræk og duplikér i én samlet grænseflade." + }, + "mergePdf": { + "name": "Flet PDF", + "subtitle": "Kombinér flere PDF’er til én fil. Bevarer bogmærker." + }, + "splitPdf": { + "name": "Opdel PDF", + "subtitle": "Udtræk et sideinterval til en ny PDF." + }, + "compressPdf": { + "name": "Komprimér PDF", + "subtitle": "Reducer filstørrelsen på din PDF.", + "algorithmLabel": "Komprimeringsalgoritme", + "condense": "Kondenser (anbefalet)", + "photon": "Photon (til billedtunge PDF’er)", + "condenseInfo": "Kondenser bruger avanceret komprimering: fjerner overflødigt indhold, optimerer billeder, udvælger skrifttyper. Bedst til de fleste PDF’er.", + "photonInfo": "Photon konverterer sider til billeder. Bruges til billedtunge eller scannede PDF’er.", + "photonWarning": "Advarsel: Tekst bliver ikke valgbar, og links holder op med at virke.", + "levelLabel": "Komprimeringsniveau", + "light": "Let (bevar kvalitet)", + "balanced": "Balanceret (anbefalet)", + "aggressive": "Aggressiv (mindre filer)", + "extreme": "Ekstrem (maksimal komprimering)", + "grayscale": "Konverter til gråtoner", + "grayscaleHint": "Reducerer filstørrelsen ved at fjerne farveinformation", + "customSettings": "Brugerdefinerede indstillinger", + "customSettingsHint": "Finjustér komprimeringsparametre:", + "outputQuality": "Outputkvalitet", + "resizeImagesTo": "Skalér billeder til", + "onlyProcessAbove": "Behandl kun over", + "removeMetadata": "Fjern metadata", + "subsetFonts": "Delvis skrifttypeindlæsning (fjern ubrugte tegn)", + "removeThumbnails": "Fjern indlejrede miniaturer", + "compressButton": "Komprimér PDF" + }, + "pdfEditor": { + "name": "PDF-editor", + "subtitle": "Annotér, fremhæv, redigér, kommentér, tilføj former/billeder, søg og vis PDF’er." + }, + "jpgToPdf": { + "name": "JPG til PDF", + "subtitle": "Opret en PDF fra JPG, JPEG og JPEG2000 (JP2/JPX) billeder." + }, + "signPdf": { + "name": "Underskriv PDF", + "subtitle": "Tegn, skriv eller upload din signatur." + }, + "cropPdf": { + "name": "Beskær PDF", + "subtitle": "Trim margenerne på alle sider i din PDF." + }, + "extractPages": { + "name": "Udtræk sider", + "subtitle": "Gem et udvalg af sider som nye filer." + }, + "duplicateOrganize": { + "name": "Duplikér og organisér", + "subtitle": "Duplikér, omorganisér og slet sider." + }, + "deletePages": { + "name": "Slet sider", + "subtitle": "Fjern specifikke sider fra dokumentet." + }, + "editBookmarks": { + "name": "Redigér bogmærker", + "subtitle": "Tilføj, redigér, importér, slet og udtræk PDF-bogmærker." + }, + "tableOfContents": { + "name": "Indholdsfortegnelse", + "subtitle": "Generér en indholdsfortegnelse ud fra PDF-bogmærker." + }, + "pageNumbers": { + "name": "Sidetal", + "subtitle": "Indsæt sidetal i dokumentet." + }, + "batesNumbering": { + "name": "Bates-nummerering", + "subtitle": "Tilføj sekventielle Bates-numre på tværs af en eller flere PDF-filer." + }, + "addWatermark": { + "name": "Tilføj vandmærke", + "subtitle": "Placer tekst eller et billede oven på dine PDF-sider.", + "applyToAllPages": "Anvend på alle sider" + }, + "headerFooter": { + "name": "Sidehoved og sidefod", + "subtitle": "Tilføj tekst øverst og nederst på siderne." + }, + "invertColors": { + "name": "Invertér farver", + "subtitle": "Lav en slags “dark mode”-version af din PDF." + }, + "scannerEffect": { + "name": "Scannereffekt", + "subtitle": "Få din PDF til at ligne et scannet dokument.", + "scanSettings": "Scanneindstillinger", + "colorspace": "Farverum", + "gray": "Grå", + "border": "Kant", + "rotate": "Rotér", + "rotateVariance": "Rotationsvariation", + "brightness": "Lysstyrke", + "contrast": "Kontrast", + "blur": "Sløring", + "noise": "Støj", + "yellowish": "Gulskær", + "resolution": "Opløsning", + "processButton": "Anvend scannereffekt" + }, + "adjustColors": { + "name": "Justér farver", + "subtitle": "Finjustér lysstyrke, kontrast, mætning og mere i din PDF.", + "colorSettings": "Farveindstillinger", + "brightness": "Lysstyrke", + "contrast": "Kontrast", + "saturation": "Mætning", + "hueShift": "Farvetonejustering", + "temperature": "Temperatur", + "tint": "Farvetone", + "gamma": "Gamma", + "sepia": "Sepia", + "processButton": "Anvend farvejusteringer" + }, + "backgroundColor": { + "name": "Baggrundsfarve", + "subtitle": "Skift baggrundsfarven på din PDF." + }, + "changeTextColor": { + "name": "Skift tekstfarve", + "subtitle": "Ændr tekstfarven i din PDF." + }, + "addStamps": { + "name": "Tilføj stempler", + "subtitle": "Tilføj billedstempler til din PDF via annoteringsværktøjet.", + "usernameLabel": "Stempelbrugernavn", + "usernamePlaceholder": "Indtast dit navn (til stempler)", + "usernameHint": "Dette navn vises på de stempler du opretter." + }, + "removeAnnotations": { + "name": "Fjern annotationer", + "subtitle": "Fjern kommentarer, markeringer og links." + }, + "pdfFormFiller": { + "name": "PDF-formularudfylder", + "subtitle": "Udfyld formularer direkte i browseren. Understøtter også XFA-formularer." + }, + "createPdfForm": { + "name": "Opret PDF-formular", + "subtitle": "Lav udfyldelige PDF-formularer med træk-og-slip tekstfelter." + }, + "removeBlankPages": { + "name": "Fjern tomme sider", + "subtitle": "Find og fjern automatisk tomme sider.", + "sensitivityHint": "Højere = strengere, kun helt tomme sider. Lavere = tillader sider med noget indhold." + }, + "imageToPdf": { + "name": "Billeder til PDF", + "subtitle": "Konverter JPG, PNG, BMP, GIF, TIFF, PNM, PGM, PBM, PPM, PAM, JXR, JPX, JP2, PSD, SVG, HEIC og WebP til PDF." + }, + "pngToPdf": { + "name": "PNG til PDF", + "subtitle": "Opret en PDF fra en eller flere PNG-billeder." + }, + "webpToPdf": { + "name": "WebP til PDF", + "subtitle": "Opret en PDF fra en eller flere WebP-billeder." + }, + "svgToPdf": { + "name": "SVG til PDF", + "subtitle": "Opret en PDF fra en eller flere SVG-billeder." + }, + "bmpToPdf": { + "name": "BMP til PDF", + "subtitle": "Opret en PDF fra en eller flere BMP-billeder." + }, + "heicToPdf": { + "name": "HEIC til PDF", + "subtitle": "Opret en PDF fra en eller flere HEIC-billeder." + }, + "tiffToPdf": { + "name": "TIFF til PDF", + "subtitle": "Opret en PDF fra en eller flere TIFF-billeder." + }, + "textToPdf": { + "name": "Tekst til PDF", + "subtitle": "Konverter en almindelig tekstfil til PDF." + }, + "jsonToPdf": { + "name": "JSON til PDF", + "subtitle": "Konverter JSON-filer til PDF." + }, + "pdfToJpg": { + "name": "PDF til JPG", + "subtitle": "Konverter hver PDF-side til en JPG-billedfil." + }, + "pdfToPng": { + "name": "PDF til PNG", + "subtitle": "Konverter hver PDF-side til en PNG-billedfil." + }, + "pdfToWebp": { + "name": "PDF til WebP", + "subtitle": "Konverter hver PDF-side til en WebP-billedfil." + }, + "pdfToBmp": { + "name": "PDF til BMP", + "subtitle": "Konverter hver PDF-side til en BMP-billedfil." + }, + "pdfToTiff": { + "name": "PDF til TIFF", + "subtitle": "Konverter hver PDF-side til en TIFF-billedfil." + }, + "pdfToGreyscale": { + "name": "PDF til gråtoner", + "subtitle": "Konverter alle farver til sort/hvid." + }, + "pdfToJson": { + "name": "PDF til JSON", + "subtitle": "Konverter PDF-filer til JSON-format." + }, + "ocrPdf": { + "name": "OCR PDF", + "subtitle": "Gør PDF’en søgbar og kopierbar." + }, + "alternateMix": { + "name": "Alternér og miks sider", + "subtitle": "Flet PDF’er ved at skifte mellem sider fra hver fil. Bevarer bogmærker." + }, + "addAttachments": { + "name": "Tilføj vedhæftninger", + "subtitle": "Indlejr en eller flere filer i din PDF." + }, + "extractAttachments": { + "name": "Udtræk vedhæftninger", + "subtitle": "Udtræk alle indlejrede filer som en ZIP." + }, + "editAttachments": { + "name": "Redigér vedhæftninger", + "subtitle": "Se eller fjern vedhæftninger i din PDF." + }, + "dividePages": { + "name": "Opdel sider", + "subtitle": "Opdel sider vandret eller lodret." + }, + "addBlankPage": { + "name": "Tilføj tom side", + "subtitle": "Indsæt en tom side hvor som helst i din PDF." + }, + "reversePages": { + "name": "Vend sider", + "subtitle": "Vend rækkefølgen på alle sider." + }, + "rotatePdf": { + "name": "Roter PDF", + "subtitle": "Drej sider i intervaller på 90 grader." + }, + "rotateCustom": { + "name": "Roter med brugerdefineret vinkel", + "subtitle": "Roter sider med en valgfri vinkel." + }, + "nUpPdf": { + "name": "N-Up PDF", + "subtitle": "Arrangér flere sider på ét ark." + }, + "combineToSinglePage": { + "name": "Kombinér til én side", + "subtitle": "Sy alle sider sammen til én lang rulle." + }, + "viewMetadata": { + "name": "Vis metadata", + "subtitle": "Inspektér skjulte PDF-egenskaber." + }, + "editMetadata": { + "name": "Redigér metadata", + "subtitle": "Redigér forfatter, titel og andre egenskaber." + }, + "pdfsToZip": { + "name": "PDF’er til ZIP", + "subtitle": "Pak flere PDF-filer i et ZIP-arkiv." + }, + "comparePdfs": { + "name": "Sammenlign PDF’er", + "subtitle": "Sammenlign to PDF’er side om side.", + "firstPdf": "Første PDF", + "secondPdf": "Anden PDF", + "clickOrDrop": "Klik eller slip", + "page": "Side", + "overlay": "Overlejring", + "sideBySide": "Side om side", + "flicker": "Blink", + "syncScroll": "Synkroniser rulning", + "export": "Eksportér", + "exportAsPdf": "Eksportér som PDF", + "splitView": "Opdelt visning", + "alternating": "Skiftevis", + "leftDocument": "Venstre dokument", + "rightDocument": "Højre dokument", + "original": "Original", + "modified": "Ændret", + "searchChanges": "Søg ændringer...", + "deleted": "Slettet", + "added": "Tilføjet", + "prevPage": "Forrige side", + "nextPage": "Næste side", + "prevChange": "Forrige ændring", + "nextChange": "Næste ændring", + "uploadTwoPdfs": "Upload to PDF’er for at se forskellene.", + "noDifferences": "Ingen forskelle fundet på denne side.", + "noMatchingChanges": "Ingen ændringer matcher det aktuelle filter.", + "pageNotExist": "Side {{page}} findes ikke i denne PDF.", + "noPairedPage": "Ingen parret side for denne side.", + "buildingModel": "Opbygger sideparringsmodel...", + "indexingPdf": "Indekserer PDF {{num}}, side {{page}} af {{total}}...", + "loadingComparison": "Indlæser sammenligning {{current}} af {{total}}...", + "runningOcr": "Kører OCR på side {{page}}...", + "preparingExport": "Forbereder PDF-eksport...", + "renderingPage": "Renderer side {{current}} af {{total}}...", + "exportError": "Eksportfejl", + "exportFailed": "Kunne ikke eksportere sammenlignings-PDF.", + "loadingFile": "Indlæser {{name}}...", + "invalidFile": "Ugyldig fil", + "invalidFileMsg": "Vælg venligst en gyldig PDF-fil.", + "loadError": "Kunne ikke indlæse PDF. Den kan være beskadiget eller beskyttet med adgangskode." + }, + "posterizePdf": { + "name": "Posterisér PDF", + "subtitle": "Opdel en stor side i flere mindre sider." + }, + "fixPageSize": { + "name": "Ret sidestørrelse", + "subtitle": "Standardisér alle sider til samme størrelse." + }, + "linearizePdf": { + "name": "Lineariser PDF", + "subtitle": "Optimer PDF til hurtig visning på nettet." + }, + "pageDimensions": { + "name": "Sidestørrelser", + "subtitle": "Analysér sidestørrelse, orientering og enheder." + }, + "removeRestrictions": { + "name": "Fjern begrænsninger", + "subtitle": "Fjern adgangskoder og sikkerhedsbegrænsninger fra digitalt signerede PDF’er." + }, + "repairPdf": { + "name": "Reparér PDF", + "subtitle": "Gendan data fra beskadigede eller korrupte PDF’er." + }, + "encryptPdf": { + "name": "Kryptér PDF", + "subtitle": "Lås din PDF med en adgangskode." + }, + "sanitizePdf": { + "name": "Rens PDF", + "subtitle": "Fjern metadata, annotationer, scripts og mere." + }, + "decryptPdf": { + "name": "Dekryptér PDF", + "subtitle": "Fjern adgangskodebeskyttelse." + }, + "flattenPdf": { + "name": "Flatten PDF", + "subtitle": "Gør formularfelter og annotationer ikke-redigerbare." + }, + "removeMetadata": { + "name": "Fjern metadata", + "subtitle": "Fjern skjulte data fra PDF’en." + }, + "changePermissions": { + "name": "Skift tilladelser", + "subtitle": "Konfigurer brugerrettigheder i PDF’en." + }, + "odtToPdf": { + "name": "ODT til PDF", + "subtitle": "Konverter ODT-dokumenter til PDF. Understøtter flere filer.", + "acceptedFormats": "ODT-filer", + "convertButton": "Konverter til PDF" + }, + "csvToPdf": { + "name": "CSV til PDF", + "subtitle": "Konverter CSV-regneark til PDF. Understøtter flere filer.", + "acceptedFormats": "CSV-filer", + "convertButton": "Konverter til PDF" + }, + "rtfToPdf": { + "name": "RTF til PDF", + "subtitle": "Konverter RTF-dokumenter til PDF. Understøtter flere filer.", + "acceptedFormats": "RTF-filer", + "convertButton": "Konverter til PDF" + }, + "wordToPdf": { + "name": "Word til PDF", + "subtitle": "Konverter Word-dokumenter (DOCX, DOC, ODT, RTF) til PDF.", + "acceptedFormats": "DOCX, DOC, ODT, RTF-filer", + "convertButton": "Konverter til PDF" + }, + "excelToPdf": { + "name": "Excel til PDF", + "subtitle": "Konverter Excel-filer (XLSX, XLS, ODS, CSV) til PDF.", + "acceptedFormats": "XLSX, XLS, ODS, CSV-filer", + "convertButton": "Konverter til PDF" + }, + "powerpointToPdf": { + "name": "PowerPoint til PDF", + "subtitle": "Konverter PowerPoint-presentationer (PPTX, PPT, ODP) til PDF.", + "acceptedFormats": "PPTX, PPT, ODP-filer", + "convertButton": "Konverter til PDF" + }, + "markdownToPdf": { + "name": "Markdown til PDF", + "subtitle": "Skriv eller indsæt Markdown og eksportér som en flot formateret PDF.", + "paneMarkdown": "Markdown", + "panePreview": "Forhåndsvisning", + "btnUpload": "Upload", + "btnSyncScroll": "Synkron rulning", + "btnSettings": "Indstillinger", + "btnExportPdf": "Eksportér PDF", + "settingsTitle": "Markdown-indstillinger", + "settingsPreset": "Forudindstilling", + "presetDefault": "Standard (GFM-lignende)", + "presetCommonmark": "CommonMark (striks)", + "presetZero": "Minimal (ingen funktioner)", + "settingsOptions": "Markdown-muligheder", + "optAllowHtml": "Tillad HTML-tags", + "optBreaks": "Konverter linjeskift til
", + "optLinkify": "Lav automatisk URL’er om til links", + "optTypographer": "Typograf (smart anførselstegn m.m.)" + }, + "pdfBooklet": { + "name": "PDF-hæfte", + "subtitle": "Arrangér sider til dobbeltsidet hæfteudskrivning. Fold og hæft for at skabe et hæfte.", + "howItWorks": "Sådan fungerer det:", + "step1": "Upload en PDF-fil.", + "step2": "Siderne bliver omarrangeret i hæfterækkefølge.", + "step3": "Udskriv dobbeltsidet, vend på kort kant, fold og hæft.", + "paperSize": "Papirstørrelse", + "orientation": "Retning", + "portrait": "Portræt", + "landscape": "Landskab", + "pagesPerSheet": "Sider pr. ark", + "createBooklet": "Opret hæfte", + "processing": "Behandler...", + "pageCount": "Sidetal bliver justeret til et multiplum af 4 om nødvendigt." + }, + "xpsToPdf": { + "name": "XPS til PDF", + "subtitle": "Konverter XPS/OXPS-dokumenter til PDF. Understøtter flere filer.", + "acceptedFormats": "XPS, OXPS-filer", + "convertButton": "Konverter til PDF" + }, + "mobiToPdf": { + "name": "MOBI til PDF", + "subtitle": "Konverter MOBI e-bøger til PDF. Understøtter flere filer.", + "acceptedFormats": "MOBI-filer", + "convertButton": "Konverter til PDF" + }, + "epubToPdf": { + "name": "EPUB til PDF", + "subtitle": "Konverter EPUB e-bøger til PDF. Understøtter flere filer.", + "acceptedFormats": "EPUB-filer", + "convertButton": "Konverter til PDF" + }, + "fb2ToPdf": { + "name": "FB2 til PDF", + "subtitle": "Konverter FictionBook (FB2) e-bøger til PDF. Understøtter flere filer.", + "acceptedFormats": "FB2-filer", + "convertButton": "Konverter til PDF" + }, + "cbzToPdf": { + "name": "CBZ til PDF", + "subtitle": "Konverter tegneseriearkiver (CBZ/CBR) til PDF. Understøtter flere filer.", + "acceptedFormats": "CBZ, CBR-filer", + "convertButton": "Konverter til PDF" + }, + "wpdToPdf": { + "name": "WPD til PDF", + "subtitle": "Konverter WordPerfect-dokumenter (WPD) til PDF.", + "acceptedFormats": "WPD-filer", + "convertButton": "Konverter til PDF" + }, + "wpsToPdf": { + "name": "WPS til PDF", + "subtitle": "Konverter WPS Office-dokumenter til PDF.", + "acceptedFormats": "WPS-filer", + "convertButton": "Konverter til PDF" + }, + "xmlToPdf": { + "name": "XML til PDF", + "subtitle": "Konverter XML-dokumenter til PDF. Understøtter flere filer.", + "acceptedFormats": "XML-filer", + "convertButton": "Konverter til PDF" + }, + "pagesToPdf": { + "name": "Pages til PDF", + "subtitle": "Konverter Apple Pages-dokumenter til PDF.", + "acceptedFormats": "Pages-filer", + "convertButton": "Konverter til PDF" + }, + "odgToPdf": { + "name": "ODG til PDF", + "subtitle": "Konverter OpenDocument Graphics (ODG) filer til PDF.", + "acceptedFormats": "ODG-filer", + "convertButton": "Konverter til PDF" + }, + "odsToPdf": { + "name": "ODS til PDF", + "subtitle": "Konverter OpenDocument Spreadsheet (ODS) filer til PDF.", + "acceptedFormats": "ODS-filer", + "convertButton": "Konverter til PDF" + }, + "odpToPdf": { + "name": "ODP til PDF", + "subtitle": "Konverter OpenDocument Presentation (ODP) filer til PDF.", + "acceptedFormats": "ODP-filer", + "convertButton": "Konverter til PDF" + }, + "pubToPdf": { + "name": "PUB til PDF", + "subtitle": "Konverter Microsoft Publisher (PUB) filer til PDF.", + "acceptedFormats": "PUB-filer", + "convertButton": "Konverter til PDF" + }, + "vsdToPdf": { + "name": "VSD til PDF", + "subtitle": "Konverter Microsoft Visio (VSD, VSDX) filer til PDF.", + "acceptedFormats": "VSD, VSDX-filer", + "convertButton": "Konverter til PDF" + }, + "psdToPdf": { + "name": "PSD til PDF", + "subtitle": "Konverter Adobe Photoshop (PSD) filer til PDF.", + "acceptedFormats": "PSD-filer", + "convertButton": "Konverter til PDF" + }, + "pdfToSvg": { + "name": "PDF til SVG", + "subtitle": "Konverter hver side i en PDF til en skalerbar vektorgrafik (SVG) med perfekt kvalitet i alle størrelser." + }, + "extractTables": { + "name": "Udtræk PDF-tabeller", + "subtitle": "Udtræk tabeller fra PDF og eksportér som CSV, JSON eller Markdown." + }, + "pdfToCsv": { + "name": "PDF til CSV", + "subtitle": "Udtræk tabeller fra PDF og konverter til CSV." + }, + "pdfToExcel": { + "name": "PDF til Excel", + "subtitle": "Udtræk tabeller fra PDF og konverter til Excel (XLSX)." + }, + "pdfToText": { + "name": "PDF til tekst", + "subtitle": "Udtræk tekst fra PDF og gem som almindelig tekst (.txt). Understøtter flere filer.", + "note": "Dette værktøj virker KUN med digitalt oprettede PDF’er. Brug OCR PDF til scannede dokumenter.", + "convertButton": "Udtræk tekst" + }, + "digitalSignPdf": { + "name": "Digital signatur PDF", + "pageTitle": "Digital signatur PDF - Tilføj kryptografisk signatur | BentoPDF", + "subtitle": "Tilføj en digital signatur til din PDF med X.509-certifikater. Understøtter PKCS#12 (.pfx, .p12) og PEM. Din private nøgle forlader aldrig browseren.", + "certificateSection": "Certifikat", + "uploadCert": "Upload certifikat (.pfx, .p12)", + "certPassword": "Certifikatkodeord", + "certPasswordPlaceholder": "Indtast kodeord til certifikat", + "certInfo": "Certifikatinformation", + "certSubject": "Emne", + "certIssuer": "Udsteder", + "certValidity": "Gyldighed", + "signatureDetails": "Signaturdetaljer (valgfrit)", + "reason": "Årsag", + "reasonPlaceholder": "Fx: Jeg godkender dette dokument", + "location": "Lokation", + "locationPlaceholder": "Fx: København, Danmark", + "contactInfo": "Kontaktinfo", + "contactPlaceholder": "Fx: email@example.com", + "applySignature": "Anvend digital signatur", + "successMessage": "PDF signeret! Signaturen kan verificeres i enhver PDF-læser." + }, + "validateSignaturePdf": { + "name": "Validér PDF-signatur", + "pageTitle": "Validér PDF-signatur - Verificér digitale signaturer | BentoPDF", + "subtitle": "Tjek digitale signaturer i dine PDF’er. Verificér certifikater, se underskriverdetaljer og bekræft dokumentintegritet." + }, + "emailToPdf": { + "name": "Email til PDF", + "subtitle": "Konverter e-mailfiler (EML, MSG) til PDF. Understøtter Outlook-formater.", + "acceptedFormats": "EML, MSG-filer", + "convertButton": "Konverter til PDF" + }, + "fontToOutline": { + "name": "Skrifttype til kontur", + "subtitle": "Konverter alle skrifttyper til vektorkonturer for ensartet visning." + }, + "deskewPdf": { + "name": "Ret skæve sider", + "subtitle": "Ret automatisk skæve scannede sider med OpenCV." + }, + "pdfToWord": { + "name": "PDF til Word", + "subtitle": "Konverter PDF-filer til redigerbare Word-dokumenter." + }, + "extractImages": { + "name": "Udtræk billeder", + "subtitle": "Udtræk alle indlejrede billeder fra PDF-filer." + }, + "pdfToMarkdown": { + "name": "PDF til Markdown", + "subtitle": "Konverter PDF-tekst og tabeller til Markdown." + }, + "preparePdfForAi": { + "name": "Forbered PDF til AI", + "subtitle": "Udtræk PDF-indhold som LlamaIndex JSON til RAG/LLM workflows." + }, + "pdfOcg": { + "name": "PDF OCG", + "subtitle": "Se, skift, tilføj og slet OCG-lag i din PDF." + }, + "pdfToPdfa": { + "name": "PDF til PDF/A", + "subtitle": "Konverter PDF til PDF/A til langtidsarkivering." + }, + "rasterizePdf": { + "name": "Rasterisér PDF", + "subtitle": "Konverter PDF til en billedbaseret PDF. Flatten lag og fjern valgbare tekster." + }, + "pdfWorkflow": { + "name": "PDF Workflow-bygger", + "subtitle": "Byg tilpassede PDF-behandlingspipelines med en visuel nodeeditor.", + "nodes": "Noder", + "searchNodes": "Søg noder...", + "run": "Kør", + "clear": "Ryd", + "save": "Gem", + "load": "Indlæs", + "export": "Eksportér", + "import": "Importér", + "ready": "Klar", + "settings": "Indstillinger", + "processing": "Behandler...", + "saveTemplate": "Gem skabelon", + "templateName": "Skabelonnavn", + "templatePlaceholder": "f.eks. Faktura-workflow", + "cancel": "Annuller", + "loadTemplate": "Indlæs skabelon", + "noTemplates": "Ingen gemte skabeloner endnu.", + "ok": "OK", + "workflowCompleted": "Workflow fuldført", + "errorDuringExecution": "Fejl under udførelse", + "addNodeError": "Tilføj mindst én node for at køre workflowet.", + "needInputOutput": "Dit workflow skal have mindst én inputnode og én outputnode for at kunne køre.", + "enterName": "Indtast venligst et navn.", + "templateExists": "En skabelon med dette navn findes allerede.", + "templateSaved": "Skabelon \"{{name}}\" gemt.", + "templateLoaded": "Skabelon \"{{name}}\" indlæst.", + "failedLoadTemplate": "Kunne ikke indlæse skabelon.", + "noSettings": "Ingen konfigurerbare indstillinger for denne node.", + "advancedSettings": "Avancerede indstillinger" + } +} diff --git a/public/locales/de/common.json b/public/locales/de/common.json index cc5aef9..5adca43 100644 --- a/public/locales/de/common.json +++ b/public/locales/de/common.json @@ -66,7 +66,34 @@ "pdfOrImages": "PDFs oder Bilder", "filesNeverLeave": "Deine Dateien verlassen nie dein Gerät.", "addMore": "Weitere Dateien hinzufügen", - "clearAll": "Alle löschen" + "clearAll": "Alle löschen", + "clearFiles": "Dateien löschen", + "hints": { + "singlePdf": "Eine einzelne PDF-Datei", + "pdfFile": "PDF-Datei", + "multiplePdfs2": "Mehrere PDF-Dateien (mindestens 2)", + "bmpImages": "BMP-Bilder", + "oneOrMorePdfs": "Eine oder mehrere PDF-Dateien", + "pdfDocuments": "PDF-Dokumente", + "oneOrMoreCsv": "Eine oder mehrere CSV-Dateien", + "multiplePdfsSupported": "Mehrere PDF-Dateien unterstützt", + "singleOrMultiplePdfs": "Einzelne oder mehrere PDF-Dateien unterstützt", + "singlePdfFile": "Einzelne PDF-Datei", + "pdfWithForms": "PDF-Datei mit Formularfeldern", + "heicImages": "HEIC/HEIF-Bilder", + "jpgImages": "JPG, JPEG, JP2, JPX Bilder", + "pdfsOrImages": "PDFs oder Bilder", + "oneOrMoreOdt": "Eine oder mehrere ODT-Dateien", + "singlePdfOnly": "Nur eine einzelne PDF-Datei", + "pdfFiles": "PDF-Dateien", + "multiplePdfs": "Mehrere PDF-Dateien", + "pngImages": "PNG-Bilder", + "pdfFilesOneOrMore": "PDF-Dateien (eine oder mehrere)", + "oneOrMoreRtf": "Eine oder mehrere RTF-Dateien", + "svgGraphics": "SVG-Grafiken", + "tiffImages": "TIFF-Bilder", + "webpImages": "WebP-Bilder" + } }, "loader": { "processing": "Verarbeite..." @@ -226,7 +253,8 @@ "error": "Fehler", "success": "Erfolgreich", "file": "Datei", - "files": "Dateien" + "files": "Dateien", + "close": "Schließen" }, "about": { "hero": { @@ -319,5 +347,18 @@ "errorRendering": "Fehler beim Rendern der Seitenvorschau", "error": "Fehler", "failedToLoad": "Laden fehlgeschlagen" + }, + "howItWorks": { + "title": "So funktioniert's", + "step1": "Klicken oder Datei hierher ziehen", + "step2": "Klicken Sie auf die Verarbeitungsschaltfläche", + "step3": "Speichern Sie Ihre verarbeitete Datei sofort" + }, + "relatedTools": { + "title": "Verwandte PDF-Tools" + }, + "simpleMode": { + "title": "PDF-Werkzeuge", + "subtitle": "Wählen Sie ein Werkzeug aus, um zu beginnen" } } diff --git a/public/locales/de/tools.json b/public/locales/de/tools.json index 769e591..b818605 100644 --- a/public/locales/de/tools.json +++ b/public/locales/de/tools.json @@ -86,9 +86,14 @@ "name": "Seitenzahlen", "subtitle": "Seitenzahlen in Ihr Dokument einfügen." }, + "batesNumbering": { + "name": "Bates-Nummerierung", + "subtitle": "Fortlaufende Bates-Nummern über eine oder mehrere PDF-Dateien hinzufügen." + }, "addWatermark": { "name": "Wasserzeichen hinzufügen", - "subtitle": "Text oder ein Bild über Ihre PDF-Seiten stempeln." + "subtitle": "Text oder ein Bild über Ihre PDF-Seiten stempeln.", + "applyToAllPages": "Auf alle Seiten anwenden" }, "headerFooter": { "name": "Kopf- & Fußzeile", @@ -98,6 +103,37 @@ "name": "Farben invertieren", "subtitle": "Eine \"Dunkelmodus\"-Version Ihrer PDF erstellen." }, + "scannerEffect": { + "name": "Scanner-Effekt", + "subtitle": "Lassen Sie Ihr PDF wie ein gescanntes Dokument aussehen.", + "scanSettings": "Scan-Einstellungen", + "colorspace": "Farbraum", + "gray": "Grau", + "border": "Rand", + "rotate": "Drehen", + "rotateVariance": "Drehvarianz", + "brightness": "Helligkeit", + "contrast": "Kontrast", + "blur": "Unschärfe", + "noise": "Rauschen", + "yellowish": "Gelbstich", + "resolution": "Auflösung", + "processButton": "Scanner-Effekt anwenden" + }, + "adjustColors": { + "name": "Farben anpassen", + "subtitle": "Helligkeit, Kontrast, Sättigung und mehr in Ihrem PDF feinabstimmen.", + "colorSettings": "Farbeinstellungen", + "brightness": "Helligkeit", + "contrast": "Kontrast", + "saturation": "Sättigung", + "hueShift": "Farbton", + "temperature": "Temperatur", + "tint": "Tönung", + "gamma": "Gamma", + "sepia": "Sepia", + "processButton": "Farbanpassungen anwenden" + }, "backgroundColor": { "name": "Hintergrundfarbe", "subtitle": "Die Hintergrundfarbe Ihrer PDF ändern." @@ -127,7 +163,8 @@ }, "removeBlankPages": { "name": "Leere Seiten entfernen", - "subtitle": "Leere Seiten automatisch erkennen und löschen." + "subtitle": "Leere Seiten automatisch erkennen und löschen.", + "sensitivityHint": "Höher = strenger, nur rein leere Seiten. Niedriger = erlaubt Seiten mit etwas Inhalt." }, "imageToPdf": { "name": "Bilder zu PDF", @@ -255,7 +292,47 @@ }, "comparePdfs": { "name": "PDFs vergleichen", - "subtitle": "Zwei PDFs nebeneinander vergleichen." + "subtitle": "Zwei PDFs nebeneinander vergleichen.", + "firstPdf": "Erste PDF", + "secondPdf": "Zweite PDF", + "clickOrDrop": "Klicken oder ablegen", + "page": "Seite", + "overlay": "Überlagerung", + "sideBySide": "Nebeneinander", + "flicker": "Flackern", + "syncScroll": "Synchrones Scrollen", + "export": "Exportieren", + "exportAsPdf": "Als PDF exportieren", + "splitView": "Geteilte Ansicht", + "alternating": "Abwechselnd", + "leftDocument": "Linkes Dokument", + "rightDocument": "Rechtes Dokument", + "original": "Original", + "modified": "Geändert", + "searchChanges": "Änderungen suchen...", + "deleted": "Gelöscht", + "added": "Hinzugefügt", + "prevPage": "Vorherige Seite", + "nextPage": "Nächste Seite", + "prevChange": "Vorherige Änderung", + "nextChange": "Nächste Änderung", + "uploadTwoPdfs": "Laden Sie zwei PDFs hoch, um Unterschiede zu sehen.", + "noDifferences": "Auf dieser Seite wurden keine Unterschiede gefunden.", + "noMatchingChanges": "Keine Änderungen entsprechen dem aktuellen Filter.", + "pageNotExist": "Seite {{page}} existiert nicht in dieser PDF.", + "noPairedPage": "Für diese Seite gibt es keine zugeordnete Seite.", + "buildingModel": "Seitenzuordnungsmodell wird erstellt...", + "indexingPdf": "PDF {{num}}, Seite {{page}} von {{total}} wird indiziert...", + "loadingComparison": "Vergleich {{current}} von {{total}} wird geladen...", + "runningOcr": "OCR wird auf Seite {{page}} ausgeführt...", + "preparingExport": "PDF-Export wird vorbereitet...", + "renderingPage": "Seite {{current}} von {{total}} wird gerendert...", + "exportError": "Exportfehler", + "exportFailed": "Vergleichs-PDF konnte nicht exportiert werden.", + "loadingFile": "{{name}} wird geladen...", + "invalidFile": "Ungültige Datei", + "invalidFileMsg": "Bitte wählen Sie eine gültige PDF-Datei aus.", + "loadError": "PDF konnte nicht geladen werden. Sie ist möglicherweise beschädigt oder passwortgeschützt." }, "posterizePdf": { "name": "PDF posterisieren", @@ -529,5 +606,66 @@ "deskewPdf": { "name": "PDF entzerren", "subtitle": "Automatisch schiefe gescannte Seiten mit OpenCV begradigen." + }, + "pdfToWord": { + "name": "PDF zu Word", + "subtitle": "PDF-Dateien in bearbeitbare Word-Dokumente umwandeln." + }, + "extractImages": { + "name": "Bilder extrahieren", + "subtitle": "Alle eingebetteten Bilder aus Ihren PDF-Dateien extrahieren." + }, + "pdfToMarkdown": { + "name": "PDF zu Markdown", + "subtitle": "PDF-Text und Tabellen in das Markdown-Format umwandeln." + }, + "preparePdfForAi": { + "name": "PDF für KI vorbereiten", + "subtitle": "PDF-Inhalte als LlamaIndex JSON für RAG/LLM-Pipelines extrahieren." + }, + "pdfOcg": { + "name": "PDF-Ebenen (OCG)", + "subtitle": "OCG-Ebenen in Ihrem PDF anzeigen, umschalten, hinzufügen und löschen." + }, + "pdfToPdfa": { + "name": "PDF zu PDF/A", + "subtitle": "PDF in PDF/A für Langzeitarchivierung umwandeln." + }, + "rasterizePdf": { + "name": "PDF rastern", + "subtitle": "PDF in bildbasiertes PDF umwandeln. Ebenen glätten und auswählbaren Text entfernen." + }, + "pdfWorkflow": { + "name": "PDF-Workflow-Builder", + "subtitle": "Erstellen Sie individuelle PDF-Verarbeitungspipelines mit einem visuellen Node-Editor.", + "nodes": "Nodes", + "searchNodes": "Nodes suchen...", + "run": "Ausführen", + "clear": "Leeren", + "save": "Speichern", + "load": "Laden", + "export": "Exportieren", + "import": "Importieren", + "ready": "Bereit", + "settings": "Einstellungen", + "processing": "Verarbeitung...", + "saveTemplate": "Vorlage speichern", + "templateName": "Vorlagenname", + "templatePlaceholder": "z. B. Rechnungs-Workflow", + "cancel": "Abbrechen", + "loadTemplate": "Vorlage laden", + "noTemplates": "Noch keine gespeicherten Vorlagen.", + "ok": "OK", + "workflowCompleted": "Workflow abgeschlossen", + "errorDuringExecution": "Fehler bei der Ausführung", + "addNodeError": "Fügen Sie mindestens einen Node hinzu, um den Workflow auszuführen.", + "needInputOutput": "Ihr Workflow benötigt mindestens einen Eingabe-Node und einen Ausgabe-Node.", + "enterName": "Bitte geben Sie einen Namen ein.", + "templateExists": "Eine Vorlage mit diesem Namen existiert bereits.", + "templateSaved": "Vorlage \"{{name}}\" gespeichert.", + "templateLoaded": "Vorlage \"{{name}}\" geladen.", + "failedLoadTemplate": "Vorlage konnte nicht geladen werden.", + "noSettings": "Keine konfigurierbaren Einstellungen für diesen Node.", + "advancedSettings": "Erweiterte Einstellungen" } } diff --git a/public/locales/en/common.json b/public/locales/en/common.json index f130779..475ee64 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -66,7 +66,43 @@ "pdfOrImages": "PDFs or Images", "filesNeverLeave": "Your files never leave your device.", "addMore": "Add More Files", - "clearAll": "Clear All" + "clearAll": "Clear All", + "clearFiles": "Clear Files", + "hints": { + "singlePdf": "A single PDF file", + "pdfFile": "PDF file", + "multiplePdfs2": "Multiple PDF files (at least 2)", + "bmpImages": "BMP images", + "oneOrMorePdfs": "One or more PDF files", + "pdfDocuments": "PDF Documents", + "oneOrMoreCsv": "One or more CSV files", + "multiplePdfsSupported": "Multiple PDF files supported", + "singleOrMultiplePdfs": "Single or multiple PDF files supported", + "singlePdfFile": "Single PDF file", + "pdfWithForms": "PDF file with form fields", + "heicImages": "HEIC/HEIF images", + "jpgImages": "JPG, JPEG, JP2, JPX Images", + "pdfsOrImages": "PDFs or Images", + "oneOrMoreOdt": "One or more ODT files", + "singlePdfOnly": "Single PDF file only", + "pdfFiles": "PDF files", + "multiplePdfs": "Multiple PDF files", + "pngImages": "PNG Images", + "pdfFilesOneOrMore": "PDF files (one or more)", + "oneOrMoreRtf": "One or more RTF files", + "svgGraphics": "SVG Graphics", + "tiffImages": "TIFF images", + "webpImages": "WebP Images" + } + }, + "howItWorks": { + "title": "How It Works", + "step1": "Click or drag and drop your file to begin", + "step2": "Click the process button to start", + "step3": "Save your processed file instantly" + }, + "relatedTools": { + "title": "Related PDF Tools" }, "loader": { "processing": "Processing..." @@ -139,6 +175,7 @@ "faq": { "title": "Frequently Asked", "questions": "Questions", + "sectionTitle": "Frequently Asked Questions", "isFree": { "question": "Is BentoPDF really free?", "answer": "Yes, absolutely. All tools on BentoPDF are 100% free to use, with no file limits, no sign-ups, and no watermarks. We believe everyone deserves access to simple, powerful PDF tools without a paywall." @@ -226,7 +263,8 @@ "error": "Error", "success": "Success", "file": "File", - "files": "Files" + "files": "Files", + "close": "Close" }, "about": { "hero": { @@ -319,5 +357,9 @@ "errorRendering": "Failed to render page thumbnails", "error": "Error", "failedToLoad": "Failed to load" + }, + "simpleMode": { + "title": "PDF Tools", + "subtitle": "Select a tool to get started" } } diff --git a/public/locales/en/tools.json b/public/locales/en/tools.json index e093480..f9d3785 100644 --- a/public/locales/en/tools.json +++ b/public/locales/en/tools.json @@ -86,9 +86,14 @@ "name": "Page Numbers", "subtitle": "Insert page numbers into your document." }, + "batesNumbering": { + "name": "Bates Numbering", + "subtitle": "Add sequential Bates numbers across one or more PDF files." + }, "addWatermark": { "name": "Add Watermark", - "subtitle": "Stamp text or an image over your PDF pages." + "subtitle": "Stamp text or an image over your PDF pages.", + "applyToAllPages": "Apply to all pages" }, "headerFooter": { "name": "Header & Footer", @@ -98,6 +103,37 @@ "name": "Invert Colors", "subtitle": "Create a \"dark mode\" version of your PDF." }, + "scannerEffect": { + "name": "Scanner Effect", + "subtitle": "Make your PDF look like a scanned document.", + "scanSettings": "Scan Settings", + "colorspace": "Colorspace", + "gray": "Gray", + "border": "Border", + "rotate": "Rotate", + "rotateVariance": "Rotate Variance", + "brightness": "Brightness", + "contrast": "Contrast", + "blur": "Blur", + "noise": "Noise", + "yellowish": "Yellowish", + "resolution": "Resolution", + "processButton": "Apply Scanner Effect" + }, + "adjustColors": { + "name": "Adjust Colors", + "subtitle": "Fine-tune brightness, contrast, saturation and more in your PDF.", + "colorSettings": "Color Settings", + "brightness": "Brightness", + "contrast": "Contrast", + "saturation": "Saturation", + "hueShift": "Hue Shift", + "temperature": "Temperature", + "tint": "Tint", + "gamma": "Gamma", + "sepia": "Sepia", + "processButton": "Apply Color Adjustments" + }, "backgroundColor": { "name": "Background Color", "subtitle": "Change the background color of your PDF." @@ -127,7 +163,8 @@ }, "removeBlankPages": { "name": "Remove Blank Pages", - "subtitle": "Automatically detect and delete blank pages." + "subtitle": "Automatically detect and delete blank pages.", + "sensitivityHint": "Higher = stricter, only purely blank pages. Lower = allows pages with some content." }, "imageToPdf": { "name": "Images to PDF", @@ -255,7 +292,47 @@ }, "comparePdfs": { "name": "Compare PDFs", - "subtitle": "Compare two PDFs side by side." + "subtitle": "Compare two PDFs side by side.", + "firstPdf": "First PDF", + "secondPdf": "Second PDF", + "clickOrDrop": "Click or drop", + "page": "Page", + "overlay": "Overlay", + "sideBySide": "Side-by-Side", + "flicker": "Flicker", + "syncScroll": "Sync scroll", + "export": "Export", + "exportAsPdf": "Export as PDF", + "splitView": "Split view", + "alternating": "Alternating", + "leftDocument": "Left Document", + "rightDocument": "Right Document", + "original": "Original", + "modified": "Modified", + "searchChanges": "Search changes...", + "deleted": "Deleted", + "added": "Added", + "prevPage": "Previous page", + "nextPage": "Next page", + "prevChange": "Previous change", + "nextChange": "Next change", + "uploadTwoPdfs": "Upload two PDFs to see differences.", + "noDifferences": "No differences detected on this page.", + "noMatchingChanges": "No changes match the current filter.", + "pageNotExist": "Page {{page}} does not exist in this PDF.", + "noPairedPage": "No paired page for this side.", + "buildingModel": "Building page pairing model...", + "indexingPdf": "Indexing PDF {{num}} page {{page}} of {{total}}...", + "loadingComparison": "Loading comparison {{current}} of {{total}}...", + "runningOcr": "Running OCR on page {{page}}...", + "preparingExport": "Preparing PDF export...", + "renderingPage": "Rendering page {{current}} of {{total}}...", + "exportError": "Export Error", + "exportFailed": "Could not export comparison PDF.", + "loadingFile": "Loading {{name}}...", + "invalidFile": "Invalid File", + "invalidFileMsg": "Please select a valid PDF file.", + "loadError": "Could not load PDF. It may be corrupt or password-protected." }, "posterizePdf": { "name": "Posterize PDF", @@ -529,5 +606,66 @@ "deskewPdf": { "name": "Deskew PDF", "subtitle": "Automatically straighten tilted scanned pages using OpenCV." + }, + "pdfToWord": { + "name": "PDF to Word", + "subtitle": "Convert PDF files to editable Word documents." + }, + "extractImages": { + "name": "Extract Images", + "subtitle": "Extract all embedded images from your PDF files." + }, + "pdfToMarkdown": { + "name": "PDF to Markdown", + "subtitle": "Convert PDF text and tables to Markdown format." + }, + "preparePdfForAi": { + "name": "Prepare PDF for AI", + "subtitle": "Extract PDF content as LlamaIndex JSON for RAG/LLM pipelines." + }, + "pdfOcg": { + "name": "PDF OCG", + "subtitle": "View, toggle, add, and delete OCG layers in your PDF." + }, + "pdfToPdfa": { + "name": "PDF to PDF/A", + "subtitle": "Convert PDF to PDF/A for long-term archiving." + }, + "rasterizePdf": { + "name": "Rasterize PDF", + "subtitle": "Convert PDF to image-based PDF. Flatten layers and remove selectable text." + }, + "pdfWorkflow": { + "name": "PDF Workflow Builder", + "subtitle": "Build custom PDF processing pipelines with a visual node editor.", + "nodes": "Nodes", + "searchNodes": "Search nodes...", + "run": "Run", + "clear": "Clear", + "save": "Save", + "load": "Load", + "export": "Export", + "import": "Import", + "ready": "Ready", + "settings": "Settings", + "processing": "Processing...", + "saveTemplate": "Save Template", + "templateName": "Template Name", + "templatePlaceholder": "e.g. Invoice Workflow", + "cancel": "Cancel", + "loadTemplate": "Load Template", + "noTemplates": "No saved templates yet.", + "ok": "OK", + "workflowCompleted": "Workflow completed", + "errorDuringExecution": "Error during execution", + "addNodeError": "Add at least one node to run the workflow.", + "needInputOutput": "Your workflow needs at least one input node and one output node to run.", + "enterName": "Please enter a name.", + "templateExists": "A template with this name already exists.", + "templateSaved": "Template \"{{name}}\" saved.", + "templateLoaded": "Template \"{{name}}\" loaded.", + "failedLoadTemplate": "Failed to load template.", + "noSettings": "No configurable settings for this node.", + "advancedSettings": "Advanced Settings" } } diff --git a/public/locales/es/common.json b/public/locales/es/common.json index a502855..690ba7b 100644 --- a/public/locales/es/common.json +++ b/public/locales/es/common.json @@ -1,323 +1,365 @@ { - "nav": { - "home": "Inicio", - "about": "Acerca de", - "contact": "Contacto", - "licensing": "Licencias", - "allTools": "Todas las Herramientas", - "openMainMenu": "Abrir menú principal", - "language": "Idioma" + "nav": { + "home": "Inicio", + "about": "Acerca de", + "contact": "Contacto", + "licensing": "Licencias", + "allTools": "Todas las Herramientas", + "openMainMenu": "Abrir menú principal", + "language": "Idioma" + }, + "donation": { + "message": "¿Te encanta BentoPDF? ¡Ayúdanos a mantenerlo gratis y de código abierto!", + "button": "Donar" + }, + "hero": { + "title": "El", + "pdfToolkit": "Kit de Herramientas PDF", + "builtForPrivacy": "diseñado para la privacidad", + "noSignups": "Sin Registro", + "unlimitedUse": "Uso Ilimitado", + "worksOffline": "Funciona Sin Conexión", + "startUsing": "Comenzar a Usar Ahora" + }, + "usedBy": { + "title": "Usado por empresas y personas que trabajan en" + }, + "features": { + "title": "¿Por qué elegir", + "bentoPdf": "BentoPDF?", + "noSignup": { + "title": "Sin Registro", + "description": "Comienza al instante, sin cuentas ni correos electrónicos." }, - "donation": { - "message": "¿Te encanta BentoPDF? ¡Ayúdanos a mantenerlo gratis y de código abierto!", - "button": "Donar" + "noUploads": { + "title": "Sin Cargas", + "description": "100% del lado del cliente, tus archivos nunca salen de tu dispositivo." }, - "hero": { - "title": "El", - "pdfToolkit": "Kit de Herramientas PDF", - "builtForPrivacy": "diseñado para la privacidad", - "noSignups": "Sin Registro", - "unlimitedUse": "Uso Ilimitado", - "worksOffline": "Funciona Sin Conexión", - "startUsing": "Comenzar a Usar Ahora" + "foreverFree": { + "title": "Gratis para Siempre", + "description": "Todas las herramientas, sin pruebas, sin restricciones de pago." }, - "usedBy": { - "title": "Usado por empresas y personas que trabajan en" + "noLimits": { + "title": "Sin Límites", + "description": "Usa tanto como quieras, sin límites ocultos." }, - "features": { - "title": "¿Por qué elegir", - "bentoPdf": "BentoPDF?", - "noSignup": { - "title": "Sin Registro", - "description": "Comienza al instante, sin cuentas ni correos electrónicos." - }, - "noUploads": { - "title": "Sin Cargas", - "description": "100% del lado del cliente, tus archivos nunca salen de tu dispositivo." - }, - "foreverFree": { - "title": "Gratis para Siempre", - "description": "Todas las herramientas, sin pruebas, sin restricciones de pago." - }, - "noLimits": { - "title": "Sin Límites", - "description": "Usa tanto como quieras, sin límites ocultos." - }, - "batchProcessing": { - "title": "Procesamiento por Lotes", - "description": "Maneja PDFs ilimitados de una sola vez." - }, - "lightningFast": { - "title": "Ultrarrápido", - "description": "Procesa PDFs al instante, sin esperas ni retrasos." - } + "batchProcessing": { + "title": "Procesamiento por Lotes", + "description": "Maneja PDFs ilimitados de una sola vez." }, - "tools": { - "title": "Comienza con", - "toolsLabel": "Herramientas", - "subtitle": "Haz clic en una herramienta para abrir el cargador de archivos", - "searchPlaceholder": "Buscar una herramienta (ej., 'dividir', 'organizar'...)", - "backToTools": "Volver a Herramientas", - "firstLoadNotice": "La primera carga toma un momento mientras descargamos nuestro motor de conversión. Después de eso, todas las cargas serán instantáneas." - }, - "upload": { - "clickToSelect": "Haz clic para seleccionar un archivo", - "orDragAndDrop": "o arrastra y suelta", - "pdfOrImages": "PDFs o Imágenes", - "filesNeverLeave": "Tus archivos nunca salen de tu dispositivo.", - "addMore": "Agregar Más Archivos", - "clearAll": "Limpiar Todo" - }, - "loader": { - "processing": "Procesando..." - }, - "alert": { - "title": "Alerta", - "ok": "OK" - }, - "preview": { - "title": "Vista Previa del Documento", - "downloadAsPdf": "Descargar como PDF", - "close": "Cerrar" - }, - "settings": { - "title": "Configuración", - "shortcuts": "Atajos", - "preferences": "Preferencias", - "displayPreferences": "Preferencias de Visualización", - "searchShortcuts": "Buscar atajos...", - "shortcutsInfo": "Mantén presionadas las teclas para establecer un atajo. Los cambios se guardan automáticamente.", - "shortcutsWarning": "⚠️ Evita los atajos comunes del navegador (Cmd/Ctrl+W, Cmd/Ctrl+T, Cmd/Ctrl+N, etc.) ya que pueden no funcionar de manera confiable.", - "import": "Importar", - "export": "Exportar", - "resetToDefaults": "Restaurar Valores Predeterminados", - "fullWidthMode": "Modo de Ancho Completo", - "fullWidthDescription": "Usa el ancho completo de la pantalla para todas las herramientas en lugar de un contenedor centrado", - "settingsAutoSaved": "La configuración se guarda automáticamente", - "clickToSet": "Haz clic para establecer", - "pressKeys": "Presiona teclas...", - "warnings": { - "alreadyInUse": "Atajo Ya en Uso", - "assignedTo": "ya está asignado a:", - "chooseDifferent": "Por favor elige un atajo diferente.", - "reserved": "Advertencia de Atajo Reservado", - "commonlyUsed": "se usa comúnmente para:", - "unreliable": "Este atajo puede no funcionar de manera confiable o puede entrar en conflicto con el comportamiento del navegador/sistema.", - "useAnyway": "¿Quieres usarlo de todos modos?", - "resetTitle": "Restablecer Atajos", - "resetMessage": "¿Estás seguro de que quieres restablecer todos los atajos a los valores predeterminados?

Esta acción no se puede deshacer.", - "importSuccessTitle": "Importación Exitosa", - "importSuccessMessage": "¡Atajos importados exitosamente!", - "importFailTitle": "Importación Fallida", - "importFailMessage": "Error al importar atajos. Formato de archivo inválido." - } - }, - "warning": { - "title": "Advertencia", - "cancel": "Cancelar", - "proceed": "Continuar" - }, - "compliance": { - "title": "Tus datos nunca salen de tu dispositivo", - "weKeep": "Mantenemos", - "yourInfoSafe": "tu información segura", - "byFollowingStandards": "siguiendo estándares de seguridad globales.", - "processingLocal": "Todo el procesamiento ocurre localmente en tu dispositivo.", - "gdpr": { - "title": "Cumplimiento GDPR", - "description": "Protege los datos personales y la privacidad de las personas dentro de la Unión Europea." - }, - "ccpa": { - "title": "Cumplimiento CCPA", - "description": "Otorga a los residentes de California derechos sobre cómo se recopila, usa y comparte su información personal." - }, - "hipaa": { - "title": "Cumplimiento HIPAA", - "description": "Establece salvaguardas para el manejo de información de salud sensible en el sistema de atención médica de Estados Unidos." - } - }, - "faq": { - "title": "Preguntas", - "questions": "Frecuentes", - "isFree": { - "question": "¿BentoPDF es realmente gratis?", - "answer": "Sí, absolutamente. Todas las herramientas en BentoPDF son 100% gratuitas, sin límites de archivos, sin registro y sin marcas de agua. Creemos que todos merecen acceso a herramientas PDF simples y potentes sin un muro de pago." - }, - "areFilesSecure": { - "question": "¿Mis archivos están seguros? ¿Dónde se procesan?", - "answer": "Tus archivos están lo más seguros posible porque nunca salen de tu computadora. Todo el procesamiento ocurre directamente en tu navegador web (del lado del cliente). Nunca cargamos tus archivos a un servidor, por lo que mantienes total privacidad y control sobre tus documentos." - }, - "platforms": { - "question": "¿Funciona en Mac, Windows y Móvil?", - "answer": "¡Sí! Dado que BentoPDF se ejecuta completamente en tu navegador, funciona en cualquier sistema operativo con un navegador web moderno, incluyendo Windows, macOS, Linux, iOS y Android." - }, - "gdprCompliant": { - "question": "¿BentoPDF cumple con GDPR?", - "answer": "Sí. BentoPDF cumple completamente con GDPR. Dado que todo el procesamiento de archivos ocurre localmente en tu navegador y nunca recopilamos ni transmitimos tus archivos a ningún servidor, no tenemos acceso a tus datos. Esto garantiza que siempre tengas el control de tus documentos." - }, - "dataStorage": { - "question": "¿Almacenan o rastrean alguno de mis archivos?", - "answer": "No. Nunca almacenamos, rastreamos ni registramos tus archivos. Todo lo que haces en BentoPDF ocurre en la memoria de tu navegador y desaparece una vez que cierras la página. No hay cargas, no hay registros de historial y no hay servidores involucrados." - }, - "different": { - "question": "¿Qué hace que BentoPDF sea diferente de otras herramientas PDF?", - "answer": "La mayoría de las herramientas PDF cargan tus archivos a un servidor para procesarlos. BentoPDF nunca hace eso. Utilizamos tecnología web moderna y segura para procesar tus archivos directamente en tu navegador. Esto significa un rendimiento más rápido, mayor privacidad y total tranquilidad." - }, - "browserBased": { - "question": "¿Cómo me mantiene seguro el procesamiento basado en navegador?", - "answer": "Al ejecutarse completamente dentro de tu navegador, BentoPDF garantiza que tus archivos nunca salgan de tu dispositivo. Esto elimina los riesgos de hackeos de servidores, violaciones de datos o accesos no autorizados. Tus archivos siguen siendo tuyos, siempre." - }, - "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." - } - }, - "testimonials": { - "title": "Lo que Nuestros", - "users": "Usuarios", - "say": "Dicen" - }, - "support": { - "title": "¿Te Gusta Mi Trabajo?", - "description": "BentoPDF es un proyecto de pasión, creado para proporcionar un kit de herramientas PDF gratuito, privado y potente para todos. Si te resulta útil, considera apoyar su desarrollo. ¡Cada café ayuda!", - "buyMeCoffee": "Cómprame un Café" - }, - "footer": { - "copyright": "© 2025 BentoPDF. Todos los derechos reservados.", - "version": "Versión", - "company": "Empresa", - "aboutUs": "Acerca de Nosotros", - "faqLink": "Preguntas Frecuentes", - "contactUs": "Contáctanos", - "legal": "Legal", - "termsAndConditions": "Términos y Condiciones", - "privacyPolicy": "Política de Privacidad", - "followUs": "Síguenos" - }, - "merge": { - "title": "Fusionar PDFs", - "description": "Combina archivos completos o selecciona páginas específicas para fusionar en un nuevo documento.", - "fileMode": "Modo Archivo", - "pageMode": "Modo Página", - "howItWorks": "Cómo funciona:", - "fileModeInstructions": [ - "Haz clic y arrastra el ícono para cambiar el orden de los archivos.", - "En el cuadro \"Páginas\" para cada archivo, puedes especificar rangos (ej., \"1-3, 5\") para fusionar solo esas páginas.", - "Deja el cuadro \"Páginas\" en blanco para incluir todas las páginas de ese archivo." - ], - "pageModeInstructions": [ - "Todas las páginas de tus PDFs cargados se muestran a continuación.", - "Simplemente arrastra y suelta las miniaturas de páginas individuales para crear el orden exacto que deseas para tu nuevo archivo." - ], - "mergePdfs": "Fusionar PDFs" - }, - "common": { - "page": "Página", - "pages": "Páginas", - "of": "de", - "download": "Descargar", - "cancel": "Cancelar", - "save": "Guardar", - "delete": "Eliminar", - "edit": "Editar", - "add": "Agregar", - "remove": "Remover", - "loading": "Cargando...", - "error": "Error", - "success": "Éxito", - "file": "Archivo", - "files": "Archivos" - }, - "about": { - "hero": { - "title": "Creemos que las herramientas PDF deben ser", - "subtitle": "rápidas, privadas y gratuitas.", - "noCompromises": "Sin compromisos." - }, - "mission": { - "title": "Nuestra Misión", - "description": "Proporcionar la caja de herramientas PDF más completa que respete tu privacidad y nunca pida pago. Creemos que las herramientas de documentos esenciales deben ser accesibles para todos, en todas partes, sin barreras." - }, - "philosophy": { - "label": "Nuestra Filosofía Central", - "title": "Privacidad Primero. Siempre.", - "description": "En una era donde los datos son una mercancía, adoptamos un enfoque diferente. Todo el procesamiento de las herramientas de Bentopdf ocurre localmente en tu navegador. Esto significa que tus archivos nunca tocan nuestros servidores, nunca vemos tus documentos y no rastreamos lo que haces. Tus documentos permanecen completa e inequívocamente privados. No es solo una característica; es nuestra base." - }, - "whyBentopdf": { - "title": "Por qué", - "speed": { - "title": "Diseñado para la Velocidad", - "description": "Sin esperar cargas o descargas a un servidor. Al procesar archivos directamente en tu navegador usando tecnologías web modernas como WebAssembly, ofrecemos una velocidad incomparable para todas nuestras herramientas." - }, - "free": { - "title": "Completamente Gratis", - "description": "Sin pruebas, sin suscripciones, sin tarifas ocultas y sin funciones \"premium\" retenidas como rehenes. Creemos que las herramientas PDF potentes deben ser una utilidad pública, no un centro de ganancias." - }, - "noAccount": { - "title": "No Requiere Cuenta", - "description": "Comienza a usar cualquier herramienta de inmediato. No necesitamos tu correo electrónico, una contraseña o cualquier información personal. Tu flujo de trabajo debe ser sin fricciones y anónimo." - }, - "openSource": { - "title": "Espíritu de Código Abierto", - "description": "Construido con transparencia en mente. Aprovechamos increíbles bibliotecas de código abierto como PDF-lib y PDF.js, y creemos en el esfuerzo impulsado por la comunidad para hacer que las herramientas potentes sean accesibles para todos." - } - }, - "cta": { - "title": "¿Listo para comenzar?", - "description": "Únete a miles de usuarios que confían en Bentopdf para sus necesidades diarias de documentos. Experimenta la diferencia que la privacidad y el rendimiento pueden hacer.", - "button": "Explorar Todas las Herramientas" - } - }, - "contact": { - "title": "Ponte en Contacto", - "subtitle": "Nos encantaría saber de ti. Ya sea que tengas una pregunta, comentario o solicitud de función, no dudes en comunicarte.", - "email": "Puedes contactarnos directamente por correo electrónico en:" - }, - "licensing": { - "title": "Licencias para", - "subtitle": "Elige la licencia que se ajuste a tus necesidades." - }, - "multiTool": { - "uploadPdfs": "Cargar PDFs", - "upload": "Cargar", - "addBlankPage": "Agregar Página en Blanco", - "edit": "Editar:", - "undo": "Deshacer", - "redo": "Rehacer", - "reset": "Restablecer", - "selection": "Selección:", - "selectAll": "Seleccionar Todo", - "deselectAll": "Deseleccionar Todo", - "rotate": "Rotar:", - "rotateLeft": "Izquierda", - "rotateRight": "Derecha", - "transform": "Transformar:", - "duplicate": "Duplicar", - "split": "Dividir", - "clear": "Limpiar:", - "delete": "Eliminar", - "download": "Descargar:", - "downloadSelected": "Descargar Seleccionados", - "exportPdf": "Exportar PDF", - "uploadPdfFiles": "Seleccionar Archivos PDF", - "dragAndDrop": "Arrastra y suelta archivos PDF aquí, o haz clic para seleccionar", - "selectFiles": "Seleccionar Archivos", - "renderingPages": "Renderizando páginas...", - "actions": { - "duplicatePage": "Duplicar esta página", - "deletePage": "Eliminar esta página", - "insertPdf": "Insertar PDF después de esta página", - "toggleSplit": "Alternar división después de esta página" - }, - "pleaseWait": "Por Favor Espera", - "pagesRendering": "Las páginas aún se están renderizando. Por favor espera...", - "noPagesSelected": "No Se Seleccionaron Páginas", - "selectOnePage": "Por favor selecciona al menos una página para descargar.", - "noPages": "Sin Páginas", - "noPagesToExport": "No hay páginas para exportar.", - "renderingTitle": "Renderizando vistas previas de páginas", - "errorRendering": "Error al renderizar miniaturas de páginas", - "error": "Error", - "failedToLoad": "Error al cargar" + "lightningFast": { + "title": "Ultrarrápido", + "description": "Procesa PDFs al instante, sin esperas ni retrasos." } + }, + "tools": { + "title": "Comienza con", + "toolsLabel": "Herramientas", + "subtitle": "Haz clic en una herramienta para abrir el cargador de archivos", + "searchPlaceholder": "Buscar una herramienta (ej., 'dividir', 'organizar'...)", + "backToTools": "Volver a Herramientas", + "firstLoadNotice": "La primera carga toma un momento mientras descargamos nuestro motor de conversión. Después de eso, todas las cargas serán instantáneas." + }, + "upload": { + "clickToSelect": "Haz clic para seleccionar un archivo", + "orDragAndDrop": "o arrastra y suelta", + "pdfOrImages": "PDFs o Imágenes", + "filesNeverLeave": "Tus archivos nunca salen de tu dispositivo.", + "addMore": "Agregar Más Archivos", + "clearAll": "Limpiar Todo", + "clearFiles": "Borrar archivos", + "hints": { + "singlePdf": "Un solo archivo PDF", + "pdfFile": "Archivo PDF", + "multiplePdfs2": "Múltiples archivos PDF (al menos 2)", + "bmpImages": "Imágenes BMP", + "oneOrMorePdfs": "Uno o más archivos PDF", + "pdfDocuments": "Documentos PDF", + "oneOrMoreCsv": "Uno o más archivos CSV", + "multiplePdfsSupported": "Múltiples archivos PDF compatibles", + "singleOrMultiplePdfs": "Uno o varios archivos PDF compatibles", + "singlePdfFile": "Un solo archivo PDF", + "pdfWithForms": "Archivo PDF con campos de formulario", + "heicImages": "Imágenes HEIC/HEIF", + "jpgImages": "Imágenes JPG, JPEG, JP2, JPX", + "pdfsOrImages": "PDFs o imágenes", + "oneOrMoreOdt": "Uno o más archivos ODT", + "singlePdfOnly": "Solo un archivo PDF", + "pdfFiles": "Archivos PDF", + "multiplePdfs": "Múltiples archivos PDF", + "pngImages": "Imágenes PNG", + "pdfFilesOneOrMore": "Archivos PDF (uno o más)", + "oneOrMoreRtf": "Uno o más archivos RTF", + "svgGraphics": "Gráficos SVG", + "tiffImages": "Imágenes TIFF", + "webpImages": "Imágenes WebP" + } + }, + "loader": { + "processing": "Procesando..." + }, + "alert": { + "title": "Alerta", + "ok": "OK" + }, + "preview": { + "title": "Vista Previa del Documento", + "downloadAsPdf": "Descargar como PDF", + "close": "Cerrar" + }, + "settings": { + "title": "Configuración", + "shortcuts": "Atajos", + "preferences": "Preferencias", + "displayPreferences": "Preferencias de Visualización", + "searchShortcuts": "Buscar atajos...", + "shortcutsInfo": "Mantén presionadas las teclas para establecer un atajo. Los cambios se guardan automáticamente.", + "shortcutsWarning": "⚠️ Evita los atajos comunes del navegador (Cmd/Ctrl+W, Cmd/Ctrl+T, Cmd/Ctrl+N, etc.) ya que pueden no funcionar de manera confiable.", + "import": "Importar", + "export": "Exportar", + "resetToDefaults": "Restaurar Valores Predeterminados", + "fullWidthMode": "Modo de Ancho Completo", + "fullWidthDescription": "Usa el ancho completo de la pantalla para todas las herramientas en lugar de un contenedor centrado", + "settingsAutoSaved": "La configuración se guarda automáticamente", + "clickToSet": "Haz clic para establecer", + "pressKeys": "Presiona teclas...", + "warnings": { + "alreadyInUse": "Atajo Ya en Uso", + "assignedTo": "ya está asignado a:", + "chooseDifferent": "Por favor elige un atajo diferente.", + "reserved": "Advertencia de Atajo Reservado", + "commonlyUsed": "se usa comúnmente para:", + "unreliable": "Este atajo puede no funcionar de manera confiable o puede entrar en conflicto con el comportamiento del navegador/sistema.", + "useAnyway": "¿Quieres usarlo de todos modos?", + "resetTitle": "Restablecer Atajos", + "resetMessage": "¿Estás seguro de que quieres restablecer todos los atajos a los valores predeterminados?

Esta acción no se puede deshacer.", + "importSuccessTitle": "Importación Exitosa", + "importSuccessMessage": "¡Atajos importados exitosamente!", + "importFailTitle": "Importación Fallida", + "importFailMessage": "Error al importar atajos. Formato de archivo inválido." + } + }, + "warning": { + "title": "Advertencia", + "cancel": "Cancelar", + "proceed": "Continuar" + }, + "compliance": { + "title": "Tus datos nunca salen de tu dispositivo", + "weKeep": "Mantenemos", + "yourInfoSafe": "tu información segura", + "byFollowingStandards": "siguiendo estándares de seguridad globales.", + "processingLocal": "Todo el procesamiento ocurre localmente en tu dispositivo.", + "gdpr": { + "title": "Cumplimiento GDPR", + "description": "Protege los datos personales y la privacidad de las personas dentro de la Unión Europea." + }, + "ccpa": { + "title": "Cumplimiento CCPA", + "description": "Otorga a los residentes de California derechos sobre cómo se recopila, usa y comparte su información personal." + }, + "hipaa": { + "title": "Cumplimiento HIPAA", + "description": "Establece salvaguardas para el manejo de información de salud sensible en el sistema de atención médica de Estados Unidos." + } + }, + "faq": { + "title": "Preguntas", + "questions": "Frecuentes", + "isFree": { + "question": "¿BentoPDF es realmente gratis?", + "answer": "Sí, absolutamente. Todas las herramientas en BentoPDF son 100% gratuitas, sin límites de archivos, sin registro y sin marcas de agua. Creemos que todos merecen acceso a herramientas PDF simples y potentes sin un muro de pago." + }, + "areFilesSecure": { + "question": "¿Mis archivos están seguros? ¿Dónde se procesan?", + "answer": "Tus archivos están lo más seguros posible porque nunca salen de tu computadora. Todo el procesamiento ocurre directamente en tu navegador web (del lado del cliente). Nunca cargamos tus archivos a un servidor, por lo que mantienes total privacidad y control sobre tus documentos." + }, + "platforms": { + "question": "¿Funciona en Mac, Windows y Móvil?", + "answer": "¡Sí! Dado que BentoPDF se ejecuta completamente en tu navegador, funciona en cualquier sistema operativo con un navegador web moderno, incluyendo Windows, macOS, Linux, iOS y Android." + }, + "gdprCompliant": { + "question": "¿BentoPDF cumple con GDPR?", + "answer": "Sí. BentoPDF cumple completamente con GDPR. Dado que todo el procesamiento de archivos ocurre localmente en tu navegador y nunca recopilamos ni transmitimos tus archivos a ningún servidor, no tenemos acceso a tus datos. Esto garantiza que siempre tengas el control de tus documentos." + }, + "dataStorage": { + "question": "¿Almacenan o rastrean alguno de mis archivos?", + "answer": "No. Nunca almacenamos, rastreamos ni registramos tus archivos. Todo lo que haces en BentoPDF ocurre en la memoria de tu navegador y desaparece una vez que cierras la página. No hay cargas, no hay registros de historial y no hay servidores involucrados." + }, + "different": { + "question": "¿Qué hace que BentoPDF sea diferente de otras herramientas PDF?", + "answer": "La mayoría de las herramientas PDF cargan tus archivos a un servidor para procesarlos. BentoPDF nunca hace eso. Utilizamos tecnología web moderna y segura para procesar tus archivos directamente en tu navegador. Esto significa un rendimiento más rápido, mayor privacidad y total tranquilidad." + }, + "browserBased": { + "question": "¿Cómo me mantiene seguro el procesamiento basado en navegador?", + "answer": "Al ejecutarse completamente dentro de tu navegador, BentoPDF garantiza que tus archivos nunca salgan de tu dispositivo. Esto elimina los riesgos de hackeos de servidores, violaciones de datos o accesos no autorizados. Tus archivos siguen siendo tuyos, siempre." + }, + "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." + }, + "sectionTitle": "Preguntas frecuentes" + }, + "testimonials": { + "title": "Lo que Nuestros", + "users": "Usuarios", + "say": "Dicen" + }, + "support": { + "title": "¿Te Gusta Mi Trabajo?", + "description": "BentoPDF es un proyecto de pasión, creado para proporcionar un kit de herramientas PDF gratuito, privado y potente para todos. Si te resulta útil, considera apoyar su desarrollo. ¡Cada café ayuda!", + "buyMeCoffee": "Cómprame un Café" + }, + "footer": { + "copyright": "© 2025 BentoPDF. Todos los derechos reservados.", + "version": "Versión", + "company": "Empresa", + "aboutUs": "Acerca de Nosotros", + "faqLink": "Preguntas Frecuentes", + "contactUs": "Contáctanos", + "legal": "Legal", + "termsAndConditions": "Términos y Condiciones", + "privacyPolicy": "Política de Privacidad", + "followUs": "Síguenos" + }, + "merge": { + "title": "Fusionar PDFs", + "description": "Combina archivos completos o selecciona páginas específicas para fusionar en un nuevo documento.", + "fileMode": "Modo Archivo", + "pageMode": "Modo Página", + "howItWorks": "Cómo funciona:", + "fileModeInstructions": [ + "Haz clic y arrastra el ícono para cambiar el orden de los archivos.", + "En el cuadro \"Páginas\" para cada archivo, puedes especificar rangos (ej., \"1-3, 5\") para fusionar solo esas páginas.", + "Deja el cuadro \"Páginas\" en blanco para incluir todas las páginas de ese archivo." + ], + "pageModeInstructions": [ + "Todas las páginas de tus PDFs cargados se muestran a continuación.", + "Simplemente arrastra y suelta las miniaturas de páginas individuales para crear el orden exacto que deseas para tu nuevo archivo." + ], + "mergePdfs": "Fusionar PDFs" + }, + "common": { + "page": "Página", + "pages": "Páginas", + "of": "de", + "download": "Descargar", + "cancel": "Cancelar", + "save": "Guardar", + "delete": "Eliminar", + "edit": "Editar", + "add": "Agregar", + "remove": "Remover", + "loading": "Cargando...", + "error": "Error", + "success": "Éxito", + "file": "Archivo", + "files": "Archivos", + "close": "Cerrar" + }, + "about": { + "hero": { + "title": "Creemos que las herramientas PDF deben ser", + "subtitle": "rápidas, privadas y gratuitas.", + "noCompromises": "Sin compromisos." + }, + "mission": { + "title": "Nuestra Misión", + "description": "Proporcionar la caja de herramientas PDF más completa que respete tu privacidad y nunca pida pago. Creemos que las herramientas de documentos esenciales deben ser accesibles para todos, en todas partes, sin barreras." + }, + "philosophy": { + "label": "Nuestra Filosofía Central", + "title": "Privacidad Primero. Siempre.", + "description": "En una era donde los datos son una mercancía, adoptamos un enfoque diferente. Todo el procesamiento de las herramientas de Bentopdf ocurre localmente en tu navegador. Esto significa que tus archivos nunca tocan nuestros servidores, nunca vemos tus documentos y no rastreamos lo que haces. Tus documentos permanecen completa e inequívocamente privados. No es solo una característica; es nuestra base." + }, + "whyBentopdf": { + "title": "Por qué", + "speed": { + "title": "Diseñado para la Velocidad", + "description": "Sin esperar cargas o descargas a un servidor. Al procesar archivos directamente en tu navegador usando tecnologías web modernas como WebAssembly, ofrecemos una velocidad incomparable para todas nuestras herramientas." + }, + "free": { + "title": "Completamente Gratis", + "description": "Sin pruebas, sin suscripciones, sin tarifas ocultas y sin funciones \"premium\" retenidas como rehenes. Creemos que las herramientas PDF potentes deben ser una utilidad pública, no un centro de ganancias." + }, + "noAccount": { + "title": "No Requiere Cuenta", + "description": "Comienza a usar cualquier herramienta de inmediato. No necesitamos tu correo electrónico, una contraseña o cualquier información personal. Tu flujo de trabajo debe ser sin fricciones y anónimo." + }, + "openSource": { + "title": "Espíritu de Código Abierto", + "description": "Construido con transparencia en mente. Aprovechamos increíbles bibliotecas de código abierto como PDF-lib y PDF.js, y creemos en el esfuerzo impulsado por la comunidad para hacer que las herramientas potentes sean accesibles para todos." + } + }, + "cta": { + "title": "¿Listo para comenzar?", + "description": "Únete a miles de usuarios que confían en Bentopdf para sus necesidades diarias de documentos. Experimenta la diferencia que la privacidad y el rendimiento pueden hacer.", + "button": "Explorar Todas las Herramientas" + } + }, + "contact": { + "title": "Ponte en Contacto", + "subtitle": "Nos encantaría saber de ti. Ya sea que tengas una pregunta, comentario o solicitud de función, no dudes en comunicarte.", + "email": "Puedes contactarnos directamente por correo electrónico en:" + }, + "licensing": { + "title": "Licencias para", + "subtitle": "Elige la licencia que se ajuste a tus necesidades." + }, + "multiTool": { + "uploadPdfs": "Cargar PDFs", + "upload": "Cargar", + "addBlankPage": "Agregar Página en Blanco", + "edit": "Editar:", + "undo": "Deshacer", + "redo": "Rehacer", + "reset": "Restablecer", + "selection": "Selección:", + "selectAll": "Seleccionar Todo", + "deselectAll": "Deseleccionar Todo", + "rotate": "Rotar:", + "rotateLeft": "Izquierda", + "rotateRight": "Derecha", + "transform": "Transformar:", + "duplicate": "Duplicar", + "split": "Dividir", + "clear": "Limpiar:", + "delete": "Eliminar", + "download": "Descargar:", + "downloadSelected": "Descargar Seleccionados", + "exportPdf": "Exportar PDF", + "uploadPdfFiles": "Seleccionar Archivos PDF", + "dragAndDrop": "Arrastra y suelta archivos PDF aquí, o haz clic para seleccionar", + "selectFiles": "Seleccionar Archivos", + "renderingPages": "Renderizando páginas...", + "actions": { + "duplicatePage": "Duplicar esta página", + "deletePage": "Eliminar esta página", + "insertPdf": "Insertar PDF después de esta página", + "toggleSplit": "Alternar división después de esta página" + }, + "pleaseWait": "Por Favor Espera", + "pagesRendering": "Las páginas aún se están renderizando. Por favor espera...", + "noPagesSelected": "No Se Seleccionaron Páginas", + "selectOnePage": "Por favor selecciona al menos una página para descargar.", + "noPages": "Sin Páginas", + "noPagesToExport": "No hay páginas para exportar.", + "renderingTitle": "Renderizando vistas previas de páginas", + "errorRendering": "Error al renderizar miniaturas de páginas", + "error": "Error", + "failedToLoad": "Error al cargar" + }, + "howItWorks": { + "title": "Cómo funciona", + "step1": "Haz clic o arrastra tu archivo aquí", + "step2": "Haz clic en el botón de procesar", + "step3": "Guarda tu archivo procesado al instante" + }, + "relatedTools": { + "title": "Herramientas PDF relacionadas" + }, + "simpleMode": { + "title": "Herramientas PDF", + "subtitle": "Selecciona una herramienta para comenzar" + } } diff --git a/public/locales/es/tools.json b/public/locales/es/tools.json index 920aa57..bd4671d 100644 --- a/public/locales/es/tools.json +++ b/public/locales/es/tools.json @@ -86,9 +86,14 @@ "name": "Números de Página", "subtitle": "Inserta números de página en tu documento." }, + "batesNumbering": { + "name": "Numeración Bates", + "subtitle": "Añadir números Bates secuenciales en uno o más archivos PDF." + }, "addWatermark": { "name": "Agregar Marca de Agua", - "subtitle": "Estampa texto o una imagen sobre tus páginas PDF." + "subtitle": "Estampa texto o una imagen sobre tus páginas PDF.", + "applyToAllPages": "Aplicar a todas las páginas" }, "headerFooter": { "name": "Encabezado y Pie de Página", @@ -98,6 +103,37 @@ "name": "Invertir Colores", "subtitle": "Crea una versión en \"modo oscuro\" de tu PDF." }, + "scannerEffect": { + "name": "Efecto escáner", + "subtitle": "Haz que tu PDF parezca un documento escaneado.", + "scanSettings": "Ajustes de escaneo", + "colorspace": "Espacio de color", + "gray": "Gris", + "border": "Borde", + "rotate": "Rotar", + "rotateVariance": "Variación de rotación", + "brightness": "Brillo", + "contrast": "Contraste", + "blur": "Desenfoque", + "noise": "Ruido", + "yellowish": "Amarillento", + "resolution": "Resolución", + "processButton": "Aplicar efecto escáner" + }, + "adjustColors": { + "name": "Ajustar colores", + "subtitle": "Ajusta brillo, contraste, saturación y más en tu PDF.", + "colorSettings": "Configuración de color", + "brightness": "Brillo", + "contrast": "Contraste", + "saturation": "Saturación", + "hueShift": "Tono", + "temperature": "Temperatura", + "tint": "Matiz", + "gamma": "Gamma", + "sepia": "Sepia", + "processButton": "Aplicar ajustes de color" + }, "backgroundColor": { "name": "Color de Fondo", "subtitle": "Cambia el color de fondo de tu PDF." @@ -127,7 +163,8 @@ }, "removeBlankPages": { "name": "Eliminar Páginas en Blanco", - "subtitle": "Detecta y elimina automáticamente páginas en blanco." + "subtitle": "Detecta y elimina automáticamente páginas en blanco.", + "sensitivityHint": "Mayor = más estricto, solo páginas completamente en blanco. Menor = permite páginas con algo de contenido." }, "imageToPdf": { "name": "Imágenes a PDF", @@ -255,7 +292,47 @@ }, "comparePdfs": { "name": "Comparar PDFs", - "subtitle": "Compara dos PDFs lado a lado." + "subtitle": "Compara dos PDFs lado a lado.", + "firstPdf": "Primer PDF", + "secondPdf": "Segundo PDF", + "clickOrDrop": "Haz clic o suelta", + "page": "Página", + "overlay": "Superposición", + "sideBySide": "Lado a lado", + "flicker": "Parpadeo", + "syncScroll": "Sincronizar desplazamiento", + "export": "Exportar", + "exportAsPdf": "Exportar como PDF", + "splitView": "Vista dividida", + "alternating": "Alternando", + "leftDocument": "Documento izquierdo", + "rightDocument": "Documento derecho", + "original": "Original", + "modified": "Modificado", + "searchChanges": "Buscar cambios...", + "deleted": "Eliminado", + "added": "Añadido", + "prevPage": "Página anterior", + "nextPage": "Página siguiente", + "prevChange": "Cambio anterior", + "nextChange": "Cambio siguiente", + "uploadTwoPdfs": "Sube dos PDFs para ver las diferencias.", + "noDifferences": "No se detectaron diferencias en esta página.", + "noMatchingChanges": "Ningún cambio coincide con el filtro actual.", + "pageNotExist": "La página {{page}} no existe en este PDF.", + "noPairedPage": "No hay una página emparejada para este lado.", + "buildingModel": "Creando el modelo de emparejamiento de páginas...", + "indexingPdf": "Indexando PDF {{num}}, página {{page}} de {{total}}...", + "loadingComparison": "Cargando comparación {{current}} de {{total}}...", + "runningOcr": "Ejecutando OCR en la página {{page}}...", + "preparingExport": "Preparando la exportación del PDF...", + "renderingPage": "Renderizando página {{current}} de {{total}}...", + "exportError": "Error de exportación", + "exportFailed": "No se pudo exportar el PDF de comparación.", + "loadingFile": "Cargando {{name}}...", + "invalidFile": "Archivo no válido", + "invalidFileMsg": "Selecciona un archivo PDF válido.", + "loadError": "No se pudo cargar el PDF. Puede estar dañado o protegido con contraseña." }, "posterizePdf": { "name": "Posterizar PDF", @@ -529,5 +606,66 @@ "deskewPdf": { "name": "Enderezar PDF", "subtitle": "Endereza automáticamente páginas escaneadas inclinadas usando OpenCV." + }, + "pdfToWord": { + "name": "PDF a Word", + "subtitle": "Convertir archivos PDF a documentos Word editables." + }, + "extractImages": { + "name": "Extraer imágenes", + "subtitle": "Extraer todas las imágenes incrustadas de sus archivos PDF." + }, + "pdfToMarkdown": { + "name": "PDF a Markdown", + "subtitle": "Convertir texto y tablas de PDF a formato Markdown." + }, + "preparePdfForAi": { + "name": "Preparar PDF para IA", + "subtitle": "Extraer contenido PDF como JSON de LlamaIndex para pipelines RAG/LLM." + }, + "pdfOcg": { + "name": "Capas PDF (OCG)", + "subtitle": "Ver, alternar, agregar y eliminar capas OCG en su PDF." + }, + "pdfToPdfa": { + "name": "PDF a PDF/A", + "subtitle": "Convertir PDF a PDF/A para archivado a largo plazo." + }, + "rasterizePdf": { + "name": "Rasterizar PDF", + "subtitle": "Convertir PDF a PDF basado en imágenes. Aplanar capas y eliminar texto seleccionable." + }, + "pdfWorkflow": { + "name": "Constructor de flujos de trabajo PDF", + "subtitle": "Cree pipelines de procesamiento PDF personalizados con un editor visual de nodos.", + "nodes": "Nodos", + "searchNodes": "Buscar nodos...", + "run": "Ejecutar", + "clear": "Limpiar", + "save": "Guardar", + "load": "Cargar", + "export": "Exportar", + "import": "Importar", + "ready": "Listo", + "settings": "Configuración", + "processing": "Procesando...", + "saveTemplate": "Guardar plantilla", + "templateName": "Nombre de la plantilla", + "templatePlaceholder": "ej. Flujo de trabajo de facturación", + "cancel": "Cancelar", + "loadTemplate": "Cargar plantilla", + "noTemplates": "Aún no hay plantillas guardadas.", + "ok": "OK", + "workflowCompleted": "Flujo de trabajo completado", + "errorDuringExecution": "Error durante la ejecución", + "addNodeError": "Agregue al menos un nodo para ejecutar el flujo de trabajo.", + "needInputOutput": "Su flujo de trabajo necesita al menos un nodo de entrada y un nodo de salida para ejecutarse.", + "enterName": "Por favor, introduzca un nombre.", + "templateExists": "Ya existe una plantilla con este nombre.", + "templateSaved": "Plantilla \"{{name}}\" guardada.", + "templateLoaded": "Plantilla \"{{name}}\" cargada.", + "failedLoadTemplate": "Error al cargar la plantilla.", + "noSettings": "No hay opciones configurables para este nodo.", + "advancedSettings": "Configuración avanzada" } } diff --git a/public/locales/fr/common.json b/public/locales/fr/common.json index fa7aec2..cbd7211 100644 --- a/public/locales/fr/common.json +++ b/public/locales/fr/common.json @@ -66,7 +66,34 @@ "pdfOrImages": "PDF ou images", "filesNeverLeave": "Vos fichiers restent sur votre appareil.", "addMore": "Ajouter d’autres fichiers", - "clearAll": "Tout effacer" + "clearAll": "Tout effacer", + "clearFiles": "Effacer les fichiers", + "hints": { + "singlePdf": "Un seul fichier PDF", + "pdfFile": "Fichier PDF", + "multiplePdfs2": "Plusieurs fichiers PDF (au moins 2)", + "bmpImages": "Images BMP", + "oneOrMorePdfs": "Un ou plusieurs fichiers PDF", + "pdfDocuments": "Documents PDF", + "oneOrMoreCsv": "Un ou plusieurs fichiers CSV", + "multiplePdfsSupported": "Plusieurs fichiers PDF pris en charge", + "singleOrMultiplePdfs": "Un ou plusieurs fichiers PDF pris en charge", + "singlePdfFile": "Un seul fichier PDF", + "pdfWithForms": "Fichier PDF avec champs de formulaire", + "heicImages": "Images HEIC/HEIF", + "jpgImages": "Images JPG, JPEG, JP2, JPX", + "pdfsOrImages": "PDFs ou images", + "oneOrMoreOdt": "Un ou plusieurs fichiers ODT", + "singlePdfOnly": "Un seul fichier PDF uniquement", + "pdfFiles": "Fichiers PDF", + "multiplePdfs": "Plusieurs fichiers PDF", + "pngImages": "Images PNG", + "pdfFilesOneOrMore": "Fichiers PDF (un ou plusieurs)", + "oneOrMoreRtf": "Un ou plusieurs fichiers RTF", + "svgGraphics": "Graphiques SVG", + "tiffImages": "Images TIFF", + "webpImages": "Images WebP" + } }, "loader": { "processing": "Traitement en cours..." @@ -170,7 +197,8 @@ "analytics": { "question": "Utilisez-vous des cookies ou des outils de suivi ?", "answer": "Nous respectons votre vie privée. BentoPDF utilise uniquement des statistiques anonymes pour connaître le nombre de visites, sans jamais identifier les utilisateurs." - } + }, + "sectionTitle": "Questions fréquemment posées" }, "testimonials": { "title": "Ce que disent", @@ -226,7 +254,8 @@ "error": "Erreur", "success": "Succès", "file": "Fichier", - "files": "Fichiers" + "files": "Fichiers", + "close": "Fermer" }, "about": { "hero": { @@ -319,5 +348,18 @@ "errorRendering": "Échec du rendu des miniatures", "error": "Erreur", "failedToLoad": "Échec du chargement" + }, + "howItWorks": { + "title": "Comment ça marche", + "step1": "Cliquez ou glissez votre fichier ici", + "step2": "Cliquez sur le bouton de traitement", + "step3": "Enregistrez votre fichier traité instantanément" + }, + "relatedTools": { + "title": "Outils PDF associés" + }, + "simpleMode": { + "title": "Outils PDF", + "subtitle": "Sélectionnez un outil pour commencer" } } diff --git a/public/locales/fr/tools.json b/public/locales/fr/tools.json index 8f36e7c..f03d44c 100644 --- a/public/locales/fr/tools.json +++ b/public/locales/fr/tools.json @@ -86,9 +86,14 @@ "name": "Numéros de page", "subtitle": "Insérer une numérotation dans le document." }, + "batesNumbering": { + "name": "Numérotation Bates", + "subtitle": "Ajouter des numéros Bates séquentiels sur un ou plusieurs fichiers PDF." + }, "addWatermark": { "name": "Ajouter un filigrane", - "subtitle": "Apposer un texte ou une image sur les pages du PDF." + "subtitle": "Apposer un texte ou une image sur les pages du PDF.", + "applyToAllPages": "Appliquer à toutes les pages" }, "headerFooter": { "name": "En-tête et pied de page", @@ -98,6 +103,37 @@ "name": "Inverser les couleurs", "subtitle": "Créer une version « mode sombre » du PDF." }, + "scannerEffect": { + "name": "Effet scanner", + "subtitle": "Donnez à votre PDF l'apparence d'un document scanné.", + "scanSettings": "Paramètres de numérisation", + "colorspace": "Espace colorimétrique", + "gray": "Gris", + "border": "Bordure", + "rotate": "Rotation", + "rotateVariance": "Variance de rotation", + "brightness": "Luminosité", + "contrast": "Contraste", + "blur": "Flou", + "noise": "Bruit", + "yellowish": "Jaunissement", + "resolution": "Résolution", + "processButton": "Appliquer l'effet scanner" + }, + "adjustColors": { + "name": "Ajuster les couleurs", + "subtitle": "Affinez la luminosité, le contraste, la saturation et plus dans votre PDF.", + "colorSettings": "Paramètres de couleur", + "brightness": "Luminosité", + "contrast": "Contraste", + "saturation": "Saturation", + "hueShift": "Teinte", + "temperature": "Température", + "tint": "Nuance", + "gamma": "Gamma", + "sepia": "Sépia", + "processButton": "Appliquer les ajustements" + }, "backgroundColor": { "name": "Couleur de fond", "subtitle": "Modifier la couleur de fond du PDF." @@ -127,7 +163,8 @@ }, "removeBlankPages": { "name": "Supprimer les pages blanches", - "subtitle": "Détecter et supprimer automatiquement les pages vides." + "subtitle": "Détecter et supprimer automatiquement les pages vides.", + "sensitivityHint": "Plus élevé = plus strict, uniquement les pages vierges. Plus bas = autorise les pages avec du contenu." }, "imageToPdf": { "name": "Images vers PDF", @@ -255,7 +292,47 @@ }, "comparePdfs": { "name": "Comparer des PDF", - "subtitle": "Comparer deux PDF côte à côte." + "subtitle": "Comparer deux PDF côte à côte.", + "firstPdf": "Premier PDF", + "secondPdf": "Deuxième PDF", + "clickOrDrop": "Cliquer ou déposer", + "page": "Page", + "overlay": "Superposition", + "sideBySide": "Côte à côte", + "flicker": "Clignotement", + "syncScroll": "Synchroniser le défilement", + "export": "Exporter", + "exportAsPdf": "Exporter en PDF", + "splitView": "Vue divisée", + "alternating": "Alterné", + "leftDocument": "Document de gauche", + "rightDocument": "Document de droite", + "original": "Original", + "modified": "Modifié", + "searchChanges": "Rechercher des modifications...", + "deleted": "Supprimé", + "added": "Ajouté", + "prevPage": "Page précédente", + "nextPage": "Page suivante", + "prevChange": "Modification précédente", + "nextChange": "Modification suivante", + "uploadTwoPdfs": "Téléversez deux PDF pour voir les différences.", + "noDifferences": "Aucune différence détectée sur cette page.", + "noMatchingChanges": "Aucune modification ne correspond au filtre actuel.", + "pageNotExist": "La page {{page}} n’existe pas dans ce PDF.", + "noPairedPage": "Aucune page associée pour ce côté.", + "buildingModel": "Création du modèle d’appariement des pages...", + "indexingPdf": "Indexation du PDF {{num}}, page {{page}} sur {{total}}...", + "loadingComparison": "Chargement de la comparaison {{current}} sur {{total}}...", + "runningOcr": "Exécution de l’OCR sur la page {{page}}...", + "preparingExport": "Préparation de l’export PDF...", + "renderingPage": "Rendu de la page {{current}} sur {{total}}...", + "exportError": "Erreur d’export", + "exportFailed": "Impossible d’exporter le PDF de comparaison.", + "loadingFile": "Chargement de {{name}}...", + "invalidFile": "Fichier invalide", + "invalidFileMsg": "Veuillez sélectionner un fichier PDF valide.", + "loadError": "Impossible de charger le PDF. Il est peut-être corrompu ou protégé par mot de passe." }, "posterizePdf": { "name": "Posteriser un PDF", @@ -529,5 +606,66 @@ "deskewPdf": { "name": "Redresser un PDF", "subtitle": "Redresser automatiquement les pages scannées inclinées à l’aide d’OpenCV." + }, + "pdfToWord": { + "name": "PDF vers Word", + "subtitle": "Convertir des fichiers PDF en documents Word modifiables." + }, + "extractImages": { + "name": "Extraire les images", + "subtitle": "Extraire toutes les images intégrées de vos fichiers PDF." + }, + "pdfToMarkdown": { + "name": "PDF vers Markdown", + "subtitle": "Convertir le texte et les tableaux PDF au format Markdown." + }, + "preparePdfForAi": { + "name": "Préparer le PDF pour l'IA", + "subtitle": "Extraire le contenu PDF en JSON LlamaIndex pour les pipelines RAG/LLM." + }, + "pdfOcg": { + "name": "Calques PDF (OCG)", + "subtitle": "Afficher, basculer, ajouter et supprimer les calques OCG de votre PDF." + }, + "pdfToPdfa": { + "name": "PDF vers PDF/A", + "subtitle": "Convertir un PDF en PDF/A pour l'archivage à long terme." + }, + "rasterizePdf": { + "name": "Rastériser le PDF", + "subtitle": "Convertir un PDF en PDF basé sur des images. Aplatir les calques et supprimer le texte sélectionnable." + }, + "pdfWorkflow": { + "name": "Constructeur de workflow PDF", + "subtitle": "Créez des pipelines de traitement PDF personnalisés avec un éditeur de nœuds visuel.", + "nodes": "Nœuds", + "searchNodes": "Rechercher des nœuds...", + "run": "Exécuter", + "clear": "Effacer", + "save": "Enregistrer", + "load": "Charger", + "export": "Exporter", + "import": "Importer", + "ready": "Prêt", + "settings": "Paramètres", + "processing": "Traitement en cours...", + "saveTemplate": "Enregistrer le modèle", + "templateName": "Nom du modèle", + "templatePlaceholder": "ex. Workflow de facturation", + "cancel": "Annuler", + "loadTemplate": "Charger un modèle", + "noTemplates": "Aucun modèle enregistré pour le moment.", + "ok": "OK", + "workflowCompleted": "Workflow terminé", + "errorDuringExecution": "Erreur lors de l'exécution", + "addNodeError": "Ajoutez au moins un nœud pour exécuter le workflow.", + "needInputOutput": "Votre workflow nécessite au moins un nœud d'entrée et un nœud de sortie pour fonctionner.", + "enterName": "Veuillez saisir un nom.", + "templateExists": "Un modèle portant ce nom existe déjà.", + "templateSaved": "Modèle \"{{name}}\" enregistré.", + "templateLoaded": "Modèle \"{{name}}\" chargé.", + "failedLoadTemplate": "Échec du chargement du modèle.", + "noSettings": "Aucun paramètre configurable pour ce nœud.", + "advancedSettings": "Paramètres avancés" } } diff --git a/public/locales/id/common.json b/public/locales/id/common.json index a07d616..f178594 100644 --- a/public/locales/id/common.json +++ b/public/locales/id/common.json @@ -66,7 +66,34 @@ "pdfOrImages": "PDF atau Gambar", "filesNeverLeave": "File Anda tidak pernah meninggalkan perangkat Anda.", "addMore": "Tambah Lebih Banyak File", - "clearAll": "Hapus Semua" + "clearAll": "Hapus Semua", + "clearFiles": "Hapus file", + "hints": { + "singlePdf": "Satu file PDF", + "pdfFile": "File PDF", + "multiplePdfs2": "Beberapa file PDF (minimal 2)", + "bmpImages": "Gambar BMP", + "oneOrMorePdfs": "Satu atau lebih file PDF", + "pdfDocuments": "Dokumen PDF", + "oneOrMoreCsv": "Satu atau lebih file CSV", + "multiplePdfsSupported": "Beberapa file PDF didukung", + "singleOrMultiplePdfs": "Satu atau beberapa file PDF didukung", + "singlePdfFile": "Satu file PDF", + "pdfWithForms": "File PDF dengan kolom formulir", + "heicImages": "Gambar HEIC/HEIF", + "jpgImages": "Gambar JPG, JPEG, JP2, JPX", + "pdfsOrImages": "PDF atau gambar", + "oneOrMoreOdt": "Satu atau lebih file ODT", + "singlePdfOnly": "Hanya satu file PDF", + "pdfFiles": "File PDF", + "multiplePdfs": "Beberapa file PDF", + "pngImages": "Gambar PNG", + "pdfFilesOneOrMore": "File PDF (satu atau lebih)", + "oneOrMoreRtf": "Satu atau lebih file RTF", + "svgGraphics": "Grafik SVG", + "tiffImages": "Gambar TIFF", + "webpImages": "Gambar WebP" + } }, "loader": { "processing": "Memproses..." @@ -170,7 +197,8 @@ "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." - } + }, + "sectionTitle": "Pertanyaan yang Sering Diajukan" }, "testimonials": { "title": "Apa Kata", @@ -226,7 +254,8 @@ "error": "Kesalahan", "success": "Berhasil", "file": "File", - "files": "File" + "files": "File", + "close": "Tutup" }, "about": { "hero": { @@ -319,5 +348,18 @@ "errorRendering": "Gagal merender thumbnail halaman", "error": "Kesalahan", "failedToLoad": "Gagal memuat" + }, + "howItWorks": { + "title": "Cara Kerja", + "step1": "Klik atau seret file Anda ke sini", + "step2": "Klik tombol proses untuk memulai", + "step3": "Simpan file yang diproses secara instan" + }, + "relatedTools": { + "title": "Alat PDF Terkait" + }, + "simpleMode": { + "title": "Alat PDF", + "subtitle": "Pilih alat untuk memulai" } } diff --git a/public/locales/id/tools.json b/public/locales/id/tools.json index 95e9bb6..bf548a7 100644 --- a/public/locales/id/tools.json +++ b/public/locales/id/tools.json @@ -86,9 +86,14 @@ "name": "Nomor Halaman", "subtitle": "Sisipkan nomor halaman ke dokumen Anda." }, + "batesNumbering": { + "name": "Penomoran Bates", + "subtitle": "Tambahkan nomor Bates berurutan pada satu atau lebih file PDF." + }, "addWatermark": { "name": "Tambah Watermark", - "subtitle": "Cap teks atau gambar di atas halaman PDF Anda." + "subtitle": "Cap teks atau gambar di atas halaman PDF Anda.", + "applyToAllPages": "Terapkan ke semua halaman" }, "headerFooter": { "name": "Header & Footer", @@ -98,6 +103,37 @@ "name": "Balik Warna", "subtitle": "Buat versi \"mode gelap\" dari PDF Anda." }, + "scannerEffect": { + "name": "Efek Pemindai", + "subtitle": "Buat PDF Anda terlihat seperti dokumen yang dipindai.", + "scanSettings": "Pengaturan Pemindaian", + "colorspace": "Ruang Warna", + "gray": "Skala Abu-abu", + "border": "Batas", + "rotate": "Putar", + "rotateVariance": "Variasi Putaran", + "brightness": "Kecerahan", + "contrast": "Kontras", + "blur": "Blur", + "noise": "Noise", + "yellowish": "Kekuningan", + "resolution": "Resolusi", + "processButton": "Terapkan Efek Pemindai" + }, + "adjustColors": { + "name": "Sesuaikan Warna", + "subtitle": "Sesuaikan kecerahan, kontras, saturasi dan lainnya pada PDF Anda.", + "colorSettings": "Pengaturan Warna", + "brightness": "Kecerahan", + "contrast": "Kontras", + "saturation": "Saturasi", + "hueShift": "Pergeseran Rona", + "temperature": "Suhu", + "tint": "Pewarnaan", + "gamma": "Gamma", + "sepia": "Sepia", + "processButton": "Terapkan Penyesuaian Warna" + }, "backgroundColor": { "name": "Warna Latar Belakang", "subtitle": "Ubah warna latar belakang PDF Anda." @@ -127,7 +163,8 @@ }, "removeBlankPages": { "name": "Hapus Halaman Kosong", - "subtitle": "Deteksi dan hapus halaman kosong secara otomatis." + "subtitle": "Deteksi dan hapus halaman kosong secara otomatis.", + "sensitivityHint": "Lebih tinggi = lebih ketat, hanya halaman yang benar-benar kosong. Lebih rendah = mengizinkan halaman dengan sedikit konten." }, "imageToPdf": { "name": "Gambar ke PDF", @@ -255,7 +292,47 @@ }, "comparePdfs": { "name": "Bandingkan PDF", - "subtitle": "Bandingkan dua PDF berdampingan." + "subtitle": "Bandingkan dua PDF berdampingan.", + "firstPdf": "PDF pertama", + "secondPdf": "PDF kedua", + "clickOrDrop": "Klik atau letakkan", + "page": "Halaman", + "overlay": "Hamparan", + "sideBySide": "Berdampingan", + "flicker": "Kedip", + "syncScroll": "Sinkronkan gulir", + "export": "Ekspor", + "exportAsPdf": "Ekspor sebagai PDF", + "splitView": "Tampilan terbagi", + "alternating": "Bergantian", + "leftDocument": "Dokumen kiri", + "rightDocument": "Dokumen kanan", + "original": "Asli", + "modified": "Diubah", + "searchChanges": "Cari perubahan...", + "deleted": "Dihapus", + "added": "Ditambahkan", + "prevPage": "Halaman sebelumnya", + "nextPage": "Halaman berikutnya", + "prevChange": "Perubahan sebelumnya", + "nextChange": "Perubahan berikutnya", + "uploadTwoPdfs": "Unggah dua PDF untuk melihat perbedaannya.", + "noDifferences": "Tidak ada perbedaan yang terdeteksi pada halaman ini.", + "noMatchingChanges": "Tidak ada perubahan yang cocok dengan filter saat ini.", + "pageNotExist": "Halaman {{page}} tidak ada di PDF ini.", + "noPairedPage": "Tidak ada halaman pasangan untuk sisi ini.", + "buildingModel": "Membangun model pemasangan halaman...", + "indexingPdf": "Mengindeks PDF {{num}} halaman {{page}} dari {{total}}...", + "loadingComparison": "Memuat perbandingan {{current}} dari {{total}}...", + "runningOcr": "Menjalankan OCR pada halaman {{page}}...", + "preparingExport": "Menyiapkan ekspor PDF...", + "renderingPage": "Merender halaman {{current}} dari {{total}}...", + "exportError": "Kesalahan ekspor", + "exportFailed": "Tidak dapat mengekspor PDF perbandingan.", + "loadingFile": "Memuat {{name}}...", + "invalidFile": "File tidak valid", + "invalidFileMsg": "Silakan pilih file PDF yang valid.", + "loadError": "Tidak dapat memuat PDF. Mungkin rusak atau dilindungi kata sandi." }, "posterizePdf": { "name": "Posterisasi PDF", @@ -529,5 +606,66 @@ "deskewPdf": { "name": "Luruskan PDF", "subtitle": "Otomatis meluruskan halaman hasil pindai yang miring menggunakan OpenCV." + }, + "pdfToWord": { + "name": "PDF ke Word", + "subtitle": "Konversi file PDF ke dokumen Word yang dapat diedit." + }, + "extractImages": { + "name": "Ekstrak Gambar", + "subtitle": "Ekstrak semua gambar yang tertanam dari file PDF Anda." + }, + "pdfToMarkdown": { + "name": "PDF ke Markdown", + "subtitle": "Konversi teks dan tabel PDF ke format Markdown." + }, + "preparePdfForAi": { + "name": "Siapkan PDF untuk AI", + "subtitle": "Ekstrak konten PDF sebagai JSON LlamaIndex untuk pipeline RAG/LLM." + }, + "pdfOcg": { + "name": "Lapisan PDF (OCG)", + "subtitle": "Lihat, beralih, tambah, dan hapus lapisan OCG di PDF Anda." + }, + "pdfToPdfa": { + "name": "PDF ke PDF/A", + "subtitle": "Konversi PDF ke PDF/A untuk pengarsipan jangka panjang." + }, + "rasterizePdf": { + "name": "Rasterisasi PDF", + "subtitle": "Konversi PDF ke PDF berbasis gambar. Ratakan lapisan dan hapus teks yang dapat dipilih." + }, + "pdfWorkflow": { + "name": "Pembangun Alur Kerja PDF", + "subtitle": "Bangun alur pemrosesan PDF kustom dengan editor node visual.", + "nodes": "Node", + "searchNodes": "Cari node...", + "run": "Jalankan", + "clear": "Bersihkan", + "save": "Simpan", + "load": "Muat", + "export": "Ekspor", + "import": "Impor", + "ready": "Siap", + "settings": "Pengaturan", + "processing": "Memproses...", + "saveTemplate": "Simpan Template", + "templateName": "Nama Template", + "templatePlaceholder": "mis. Alur Kerja Faktur", + "cancel": "Batal", + "loadTemplate": "Muat Template", + "noTemplates": "Belum ada template tersimpan.", + "ok": "OK", + "workflowCompleted": "Alur kerja selesai", + "errorDuringExecution": "Kesalahan saat eksekusi", + "addNodeError": "Tambahkan setidaknya satu node untuk menjalankan alur kerja.", + "needInputOutput": "Alur kerja Anda memerlukan setidaknya satu node input dan satu node output untuk dijalankan.", + "enterName": "Silakan masukkan nama.", + "templateExists": "Template dengan nama ini sudah ada.", + "templateSaved": "Template \"{{name}}\" tersimpan.", + "templateLoaded": "Template \"{{name}}\" dimuat.", + "failedLoadTemplate": "Gagal memuat template.", + "noSettings": "Tidak ada pengaturan yang dapat dikonfigurasi untuk node ini.", + "advancedSettings": "Pengaturan Lanjutan" } } diff --git a/public/locales/it/common.json b/public/locales/it/common.json index c7d7b81..e938a8a 100644 --- a/public/locales/it/common.json +++ b/public/locales/it/common.json @@ -66,7 +66,34 @@ "pdfOrImages": "PDF o immagini", "filesNeverLeave": "I tuoi file non lasciano mai il tuo dispositivo.", "addMore": "Aggiungi altri file", - "clearAll": "Svuota tutto" + "clearAll": "Svuota tutto", + "clearFiles": "Cancella file", + "hints": { + "singlePdf": "Un singolo file PDF", + "pdfFile": "File PDF", + "multiplePdfs2": "Più file PDF (almeno 2)", + "bmpImages": "Immagini BMP", + "oneOrMorePdfs": "Uno o più file PDF", + "pdfDocuments": "Documenti PDF", + "oneOrMoreCsv": "Uno o più file CSV", + "multiplePdfsSupported": "Più file PDF supportati", + "singleOrMultiplePdfs": "Uno o più file PDF supportati", + "singlePdfFile": "Un singolo file PDF", + "pdfWithForms": "File PDF con campi modulo", + "heicImages": "Immagini HEIC/HEIF", + "jpgImages": "Immagini JPG, JPEG, JP2, JPX", + "pdfsOrImages": "PDF o immagini", + "oneOrMoreOdt": "Uno o più file ODT", + "singlePdfOnly": "Solo un file PDF", + "pdfFiles": "File PDF", + "multiplePdfs": "Più file PDF", + "pngImages": "Immagini PNG", + "pdfFilesOneOrMore": "File PDF (uno o più)", + "oneOrMoreRtf": "Uno o più file RTF", + "svgGraphics": "Grafica SVG", + "tiffImages": "Immagini TIFF", + "webpImages": "Immagini WebP" + } }, "loader": { "processing": "Elaborazione..." @@ -170,7 +197,8 @@ "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." - } + }, + "sectionTitle": "Domande frequenti" }, "testimonials": { "title": "Cosa", @@ -226,7 +254,8 @@ "error": "Errore", "success": "Successo", "file": "File", - "files": "File" + "files": "File", + "close": "Chiudi" }, "about": { "hero": { @@ -319,5 +348,18 @@ "errorRendering": "Impossibile generare le miniature delle pagine", "error": "Errore", "failedToLoad": "Caricamento fallito" + }, + "howItWorks": { + "title": "Come funziona", + "step1": "Clicca o trascina il tuo file qui", + "step2": "Clicca il pulsante di elaborazione", + "step3": "Salva il tuo file elaborato istantaneamente" + }, + "relatedTools": { + "title": "Strumenti PDF correlati" + }, + "simpleMode": { + "title": "Strumenti PDF", + "subtitle": "Seleziona uno strumento per iniziare" } } diff --git a/public/locales/it/tools.json b/public/locales/it/tools.json index 0caf98e..0945292 100644 --- a/public/locales/it/tools.json +++ b/public/locales/it/tools.json @@ -86,9 +86,14 @@ "name": "Numeri di Pagina", "subtitle": "Inserisci i numeri di pagina nel tuo documento." }, + "batesNumbering": { + "name": "Numerazione Bates", + "subtitle": "Aggiungi numeri Bates sequenziali su uno o più file PDF." + }, "addWatermark": { "name": "Aggiungi Filigrana", - "subtitle": "Applica testo o un'immagine sulle pagine del tuo PDF." + "subtitle": "Applica testo o un'immagine sulle pagine del tuo PDF.", + "applyToAllPages": "Applica a tutte le pagine" }, "headerFooter": { "name": "Intestazione e Piè di Pagina", @@ -98,6 +103,37 @@ "name": "Inverti Colori", "subtitle": "Crea una versione \"modalità scura\" del tuo PDF." }, + "scannerEffect": { + "name": "Effetto scanner", + "subtitle": "Fai sembrare il tuo PDF un documento scansionato.", + "scanSettings": "Impostazioni di scansione", + "colorspace": "Spazio colore", + "gray": "Grigio", + "border": "Bordo", + "rotate": "Rotazione", + "rotateVariance": "Variazione rotazione", + "brightness": "Luminosità", + "contrast": "Contrasto", + "blur": "Sfocatura", + "noise": "Rumore", + "yellowish": "Ingiallimento", + "resolution": "Risoluzione", + "processButton": "Applica effetto scanner" + }, + "adjustColors": { + "name": "Regola colori", + "subtitle": "Regola luminosità, contrasto, saturazione e altro nel tuo PDF.", + "colorSettings": "Impostazioni colore", + "brightness": "Luminosità", + "contrast": "Contrasto", + "saturation": "Saturazione", + "hueShift": "Tonalità", + "temperature": "Temperatura", + "tint": "Tinta", + "gamma": "Gamma", + "sepia": "Seppia", + "processButton": "Applica regolazioni colore" + }, "backgroundColor": { "name": "Colore di Sfondo", "subtitle": "Cambia il colore di sfondo del tuo PDF." @@ -127,7 +163,8 @@ }, "removeBlankPages": { "name": "Rimuovi Pagine Vuote", - "subtitle": "Rileva e elimina automaticamente le pagine vuote." + "subtitle": "Rileva e elimina automaticamente le pagine vuote.", + "sensitivityHint": "Più alto = più rigoroso, solo pagine completamente vuote. Più basso = consente pagine con qualche contenuto." }, "imageToPdf": { "name": "Immagini in PDF", @@ -255,7 +292,47 @@ }, "comparePdfs": { "name": "Confronta PDF", - "subtitle": "Confronta due PDF fianco a fianco." + "subtitle": "Confronta due PDF fianco a fianco.", + "firstPdf": "Primo PDF", + "secondPdf": "Secondo PDF", + "clickOrDrop": "Clicca o rilascia", + "page": "Pagina", + "overlay": "Sovrapposizione", + "sideBySide": "Affiancato", + "flicker": "Lampeggio", + "syncScroll": "Sincronizza scorrimento", + "export": "Esporta", + "exportAsPdf": "Esporta come PDF", + "splitView": "Vista divisa", + "alternating": "Alternato", + "leftDocument": "Documento sinistro", + "rightDocument": "Documento destro", + "original": "Originale", + "modified": "Modificato", + "searchChanges": "Cerca modifiche...", + "deleted": "Eliminato", + "added": "Aggiunto", + "prevPage": "Pagina precedente", + "nextPage": "Pagina successiva", + "prevChange": "Modifica precedente", + "nextChange": "Modifica successiva", + "uploadTwoPdfs": "Carica due PDF per vedere le differenze.", + "noDifferences": "Nessuna differenza rilevata in questa pagina.", + "noMatchingChanges": "Nessuna modifica corrisponde al filtro corrente.", + "pageNotExist": "La pagina {{page}} non esiste in questo PDF.", + "noPairedPage": "Nessuna pagina associata per questo lato.", + "buildingModel": "Creazione del modello di abbinamento pagine...", + "indexingPdf": "Indicizzazione del PDF {{num}}, pagina {{page}} di {{total}}...", + "loadingComparison": "Caricamento confronto {{current}} di {{total}}...", + "runningOcr": "Esecuzione OCR sulla pagina {{page}}...", + "preparingExport": "Preparazione esportazione PDF...", + "renderingPage": "Rendering pagina {{current}} di {{total}}...", + "exportError": "Errore di esportazione", + "exportFailed": "Impossibile esportare il PDF di confronto.", + "loadingFile": "Caricamento di {{name}}...", + "invalidFile": "File non valido", + "invalidFileMsg": "Seleziona un file PDF valido.", + "loadError": "Impossibile caricare il PDF. Potrebbe essere danneggiato o protetto da password." }, "posterizePdf": { "name": "Posterizza PDF", @@ -529,5 +606,66 @@ "deskewPdf": { "name": "Raddrizza PDF", "subtitle": "Raddrizza automaticamente le pagine scansionate inclinate usando OpenCV." + }, + "pdfToWord": { + "name": "PDF in Word", + "subtitle": "Converti file PDF in documenti Word modificabili." + }, + "extractImages": { + "name": "Estrai immagini", + "subtitle": "Estrai tutte le immagini incorporate dai tuoi file PDF." + }, + "pdfToMarkdown": { + "name": "PDF in Markdown", + "subtitle": "Converti testo e tabelle PDF in formato Markdown." + }, + "preparePdfForAi": { + "name": "Prepara PDF per IA", + "subtitle": "Estrai il contenuto PDF come JSON LlamaIndex per pipeline RAG/LLM." + }, + "pdfOcg": { + "name": "Livelli PDF (OCG)", + "subtitle": "Visualizza, attiva/disattiva, aggiungi ed elimina livelli OCG nel tuo PDF." + }, + "pdfToPdfa": { + "name": "PDF in PDF/A", + "subtitle": "Converti PDF in PDF/A per l'archiviazione a lungo termine." + }, + "rasterizePdf": { + "name": "Rasterizza PDF", + "subtitle": "Converti PDF in PDF basato su immagini. Appiattisci i livelli e rimuovi il testo selezionabile." + }, + "pdfWorkflow": { + "name": "Costruttore di workflow PDF", + "subtitle": "Crea pipeline di elaborazione PDF personalizzate con un editor visuale a nodi.", + "nodes": "Nodi", + "searchNodes": "Cerca nodi...", + "run": "Esegui", + "clear": "Cancella", + "save": "Salva", + "load": "Carica", + "export": "Esporta", + "import": "Importa", + "ready": "Pronto", + "settings": "Impostazioni", + "processing": "Elaborazione in corso...", + "saveTemplate": "Salva modello", + "templateName": "Nome del modello", + "templatePlaceholder": "es. Workflow fatturazione", + "cancel": "Annulla", + "loadTemplate": "Carica modello", + "noTemplates": "Nessun modello salvato al momento.", + "ok": "OK", + "workflowCompleted": "Workflow completato", + "errorDuringExecution": "Errore durante l'esecuzione", + "addNodeError": "Aggiungi almeno un nodo per eseguire il workflow.", + "needInputOutput": "Il tuo workflow necessita di almeno un nodo di input e un nodo di output per funzionare.", + "enterName": "Inserisci un nome.", + "templateExists": "Esiste già un modello con questo nome.", + "templateSaved": "Modello \"{{name}}\" salvato.", + "templateLoaded": "Modello \"{{name}}\" caricato.", + "failedLoadTemplate": "Impossibile caricare il modello.", + "noSettings": "Nessuna impostazione configurabile per questo nodo.", + "advancedSettings": "Impostazioni avanzate" } } diff --git a/public/locales/ko/common.json b/public/locales/ko/common.json new file mode 100644 index 0000000..bbe93bd --- /dev/null +++ b/public/locales/ko/common.json @@ -0,0 +1,365 @@ +{ + "nav": { + "home": "홈", + "about": "소개", + "contact": "문의", + "licensing": "라이선스", + "allTools": "모든 도구", + "openMainMenu": "메인 메뉴 열기", + "language": "언어" + }, + "donation": { + "message": "BentoPDF가 마음에 드셨나요? 무료 오픈소스로 유지될 수 있도록 도와주세요!", + "button": "후원하기" + }, + "hero": { + "title": "개인정보 보호를 위해 만든", + "pdfToolkit": "PDF 도구 모음", + "builtForPrivacy": " ", + "noSignups": "회원가입 불필요", + "unlimitedUse": "무제한 사용", + "worksOffline": "오프라인 지원", + "startUsing": "지금 시작하기" + }, + "usedBy": { + "title": "다양한 기업과 사용자들이 함께하고 있습니다" + }, + "features": { + "title": "왜", + "bentoPdf": "BentoPDF일까요?", + "noSignup": { + "title": "회원가입 불필요", + "description": "계정이나 이메일 없이 바로 시작하세요." + }, + "noUploads": { + "title": "파일 업로드 없음", + "description": "100% 브라우저에서 처리되어 파일이 기기 밖으로 나가지 않습니다." + }, + "foreverFree": { + "title": "영원히 무료", + "description": "모든 도구를 무료로, 체험판도 유료 기능도 없습니다." + }, + "noLimits": { + "title": "사용 제한 없음", + "description": "원하는 만큼 자유롭게 사용하세요." + }, + "batchProcessing": { + "title": "일괄 처리", + "description": "여러 PDF를 한 번에 처리할 수 있습니다." + }, + "lightningFast": { + "title": "빠른 처리 속도", + "description": "기다릴 필요 없이 PDF를 즉시 처리합니다." + } + }, + "tools": { + "title": "시작하기", + "toolsLabel": "도구", + "subtitle": "도구를 클릭하면 파일 업로더가 열립니다", + "searchPlaceholder": "도구 검색 (예: '분할', '병합'...)", + "backToTools": "도구 목록으로 돌아가기", + "firstLoadNotice": "처음에는 변환 엔진을 다운로드하느라 시간이 조금 걸릴 수 있습니다. 이후에는 바로 실행됩니다." + }, + "upload": { + "clickToSelect": "클릭하여 파일 선택", + "orDragAndDrop": "또는 파일을 끌어다 놓으세요", + "pdfOrImages": "PDF 또는 이미지", + "filesNeverLeave": "파일이 기기 밖으로 나가지 않습니다.", + "addMore": "파일 추가", + "clearAll": "모두 지우기", + "clearFiles": "파일 지우기", + "hints": { + "singlePdf": "PDF 파일 1개", + "pdfFile": "PDF 파일", + "multiplePdfs2": "PDF 파일 2개 이상", + "bmpImages": "BMP 이미지", + "oneOrMorePdfs": "PDF 파일 1개 이상", + "pdfDocuments": "PDF 문서", + "oneOrMoreCsv": "CSV 파일 1개 이상", + "multiplePdfsSupported": "여러 PDF 파일 지원", + "singleOrMultiplePdfs": "PDF 파일 1개 또는 여러 개", + "singlePdfFile": "PDF 파일 1개", + "pdfWithForms": "양식이 포함된 PDF 파일", + "heicImages": "HEIC/HEIF 이미지", + "jpgImages": "JPG, JPEG, JP2, JPX 이미지", + "pdfsOrImages": "PDF 또는 이미지", + "oneOrMoreOdt": "ODT 파일 1개 이상", + "singlePdfOnly": "PDF 파일 1개만", + "pdfFiles": "PDF 파일", + "multiplePdfs": "여러 PDF 파일", + "pngImages": "PNG 이미지", + "pdfFilesOneOrMore": "PDF 파일 (1개 이상)", + "oneOrMoreRtf": "RTF 파일 1개 이상", + "svgGraphics": "SVG 그래픽", + "tiffImages": "TIFF 이미지", + "webpImages": "WebP 이미지" + } + }, + "howItWorks": { + "title": "이용 방법", + "step1": "파일을 클릭하거나 끌어다 놓아 시작하세요", + "step2": "처리 버튼을 눌러 변환을 시작하세요", + "step3": "완성된 파일을 바로 저장하세요" + }, + "relatedTools": { + "title": "관련 PDF 도구" + }, + "loader": { + "processing": "처리 중..." + }, + "alert": { + "title": "알림", + "ok": "확인" + }, + "preview": { + "title": "문서 미리보기", + "downloadAsPdf": "PDF로 다운로드", + "close": "닫기" + }, + "settings": { + "title": "설정", + "shortcuts": "단축키", + "preferences": "환경설정", + "displayPreferences": "화면 설정", + "searchShortcuts": "단축키 검색...", + "shortcutsInfo": "키를 길게 눌러 단축키를 설정할 수 있습니다. 변경 사항은 자동 저장됩니다.", + "shortcutsWarning": "⚠️ 브라우저 기본 단축키(Cmd/Ctrl+W, Cmd/Ctrl+T, Cmd/Ctrl+N 등)는 제대로 작동하지 않을 수 있으니 피해 주세요.", + "import": "가져오기", + "export": "내보내기", + "resetToDefaults": "기본값으로 초기화", + "fullWidthMode": "전체 너비 모드", + "fullWidthDescription": "가운데 정렬 대신 화면 전체 너비를 사용합니다", + "settingsAutoSaved": "설정은 자동으로 저장됩니다", + "clickToSet": "클릭하여 설정", + "pressKeys": "키를 누르세요...", + "warnings": { + "alreadyInUse": "이미 사용 중인 단축키", + "assignedTo": "다음 항목에 이미 지정되어 있습니다:", + "chooseDifferent": "다른 단축키를 선택해 주세요.", + "reserved": "예약된 단축키 경고", + "commonlyUsed": "일반적으로 다음 용도로 사용됩니다:", + "unreliable": "이 단축키는 브라우저나 시스템 동작과 충돌할 수 있습니다.", + "useAnyway": "그래도 사용하시겠습니까?", + "resetTitle": "단축키 초기화", + "resetMessage": "모든 단축키를 기본값으로 초기화할까요?

이 작업은 되돌릴 수 없습니다.", + "importSuccessTitle": "가져오기 완료", + "importSuccessMessage": "단축키를 가져왔습니다!", + "importFailTitle": "가져오기 실패", + "importFailMessage": "단축키를 가져올 수 없습니다. 파일 형식이 올바르지 않습니다." + } + }, + "warning": { + "title": "경고", + "cancel": "취소", + "proceed": "계속" + }, + "compliance": { + "title": "데이터가 기기 밖으로 나가지 않습니다", + "weKeep": "글로벌 보안 표준을 준수하여", + "yourInfoSafe": "여러분의 정보를", + "byFollowingStandards": "안전하게 보호합니다.", + "processingLocal": "모든 처리는 사용자의 기기에서 직접 이루어집니다.", + "gdpr": { + "title": "GDPR 준수", + "description": "유럽연합 내 개인정보 및 프라이버시를 보호합니다." + }, + "ccpa": { + "title": "CCPA 준수", + "description": "캘리포니아 주민의 개인정보 수집, 사용, 공유에 대한 권리를 보장합니다." + }, + "hipaa": { + "title": "HIPAA 준수", + "description": "미국 의료 시스템에서 민감한 건강 정보를 안전하게 보호합니다." + } + }, + "faq": { + "title": "자주 묻는", + "questions": "질문", + "sectionTitle": "자주 묻는 질문", + "isFree": { + "question": "BentoPDF는 정말 무료인가요?", + "answer": "네, 완전히 무료입니다. BentoPDF의 모든 도구는 파일 수 제한, 회원가입, 워터마크 없이 자유롭게 사용할 수 있습니다. 누구나 비용 걱정 없이 편리한 PDF 도구를 쓸 수 있어야 한다고 생각합니다." + }, + "areFilesSecure": { + "question": "파일이 안전한가요? 어디에서 처리되나요?", + "answer": "파일은 여러분의 컴퓨터를 절대 떠나지 않기 때문에 최대한 안전합니다. 모든 작업은 웹 브라우저 안에서 직접 처리되며, 서버에 파일을 업로드하지 않으므로 문서의 프라이버시를 완벽하게 지킬 수 있습니다." + }, + "platforms": { + "question": "Mac, Windows, 모바일에서도 되나요?", + "answer": "네! BentoPDF는 브라우저에서 실행되므로 Windows, macOS, Linux, iOS, Android 등 최신 웹 브라우저가 있는 어떤 기기에서든 사용할 수 있습니다." + }, + "gdprCompliant": { + "question": "BentoPDF는 GDPR을 준수하나요?", + "answer": "네, 완전히 준수합니다. 모든 파일 처리가 브라우저에서 이루어지고 서버로 파일을 전송하지 않기 때문에, 저희는 여러분의 데이터에 접근할 수 없습니다. 문서에 대한 통제권은 항상 여러분에게 있습니다." + }, + "dataStorage": { + "question": "파일을 저장하거나 추적하나요?", + "answer": "아니요. 파일을 저장하거나 추적하거나 기록하지 않습니다. BentoPDF에서 하는 모든 작업은 브라우저 메모리에서 처리되며, 페이지를 닫으면 모두 사라집니다. 업로드도, 이용 기록도, 서버도 관여하지 않습니다." + }, + "different": { + "question": "BentoPDF가 다른 PDF 도구와 다른 점은 뭔가요?", + "answer": "대부분의 PDF 도구는 파일을 서버에 업로드해서 처리합니다. BentoPDF는 그렇게 하지 않습니다. 최신 웹 기술을 활용해 브라우저에서 바로 처리하기 때문에 더 빠르고, 더 안전하며, 걱정 없이 사용할 수 있습니다." + }, + "browserBased": { + "question": "브라우저 기반 처리가 왜 안전한가요?", + "answer": "BentoPDF는 브라우저 안에서만 실행되기 때문에 파일이 기기 밖으로 나가지 않습니다. 서버 해킹, 데이터 유출, 무단 접근 같은 위험이 원천적으로 차단됩니다. 파일은 언제나 여러분만의 것입니다." + }, + "analytics": { + "question": "쿠키나 분석 도구로 사용자를 추적하나요?", + "answer": "여러분의 프라이버시를 소중히 여깁니다. BentoPDF는 개인정보를 추적하지 않으며, 익명 방문 수 확인만을 위해 Simple Analytics를 사용합니다. 방문자 수는 알 수 있지만 누가 방문했는지는 절대 알 수 없습니다. Simple Analytics는 GDPR을 완전히 준수합니다." + } + }, + "testimonials": { + "title": "사용자", + "users": "후기", + "say": "" + }, + "support": { + "title": "도움이 되셨나요?", + "description": "BentoPDF는 누구나 무료로, 안전하게, 강력한 PDF 도구를 사용할 수 있도록 만든 프로젝트입니다. 유용하게 쓰고 계시다면 개발을 응원해 주세요. 작은 후원이 큰 힘이 됩니다!", + "buyMeCoffee": "커피 한 잔 사주기" + }, + "footer": { + "copyright": "© 2026 BentoPDF. All rights reserved.", + "version": "버전", + "company": "회사", + "aboutUs": "소개", + "faqLink": "FAQ", + "contactUs": "문의하기", + "legal": "법적 고지", + "termsAndConditions": "이용약관", + "privacyPolicy": "개인정보처리방침", + "followUs": "팔로우" + }, + "merge": { + "title": "PDF 병합", + "description": "여러 파일을 합치거나, 원하는 페이지만 골라 새 문서로 만들 수 있습니다.", + "fileMode": "파일 모드", + "pageMode": "페이지 모드", + "howItWorks": "이용 방법:", + "fileModeInstructions": [ + "아이콘을 드래그하여 파일 순서를 변경할 수 있습니다.", + "각 파일의 \"페이지\" 란에 원하는 범위를 입력하세요 (예: \"1-3, 5\").", + "\"페이지\" 란을 비워두면 해당 파일의 전체 페이지가 포함됩니다." + ], + "pageModeInstructions": [ + "업로드한 PDF의 모든 페이지가 아래에 나타납니다.", + "페이지 미리보기를 드래그하여 원하는 순서로 배치하세요." + ], + "mergePdfs": "PDF 병합하기" + }, + "common": { + "page": "페이지", + "pages": "페이지", + "of": "/", + "download": "다운로드", + "cancel": "취소", + "save": "저장", + "delete": "삭제", + "edit": "편집", + "add": "추가", + "remove": "제거", + "loading": "불러오는 중...", + "error": "오류", + "success": "완료", + "file": "파일", + "files": "파일", + "close": "닫기" + }, + "about": { + "hero": { + "title": "PDF 도구는 마땅히", + "subtitle": "빠르고, 안전하고, 무료여야 합니다.", + "noCompromises": "타협 없이." + }, + "mission": { + "title": "우리의 목표", + "description": "프라이버시를 지키면서도 비용 부담 없는, 가장 완벽한 PDF 도구를 만드는 것입니다. 꼭 필요한 문서 도구는 누구나, 어디서든 자유롭게 쓸 수 있어야 합니다." + }, + "philosophy": { + "label": "핵심 철학", + "title": "개인정보 보호가 최우선. 언제나.", + "description": "데이터가 곧 상품인 시대에, 우리는 다른 길을 걷습니다. BentoPDF의 모든 처리는 브라우저에서 직접 이루어집니다. 파일이 서버로 전송되지 않고, 문서를 들여다보지 않으며, 사용 내역을 추적하지 않습니다. 여러분의 문서는 철저하게 비공개로 유지됩니다. 이것은 하나의 기능이 아니라 우리의 근본입니다." + }, + "whyBentopdf": { + "title": "왜", + "speed": { + "title": "속도에 최적화", + "description": "서버로 파일을 올리고 내려받을 필요가 없습니다. WebAssembly 등 최신 웹 기술로 브라우저에서 바로 처리하여 압도적인 속도를 제공합니다." + }, + "free": { + "title": "완전 무료", + "description": "체험판, 구독, 숨겨진 요금, 잠긴 \"프리미엄\" 기능이 없습니다. 강력한 PDF 도구는 돈을 내야 쓸 수 있는 것이 아니라 누구에게나 열려 있어야 합니다." + }, + "noAccount": { + "title": "계정 없이 바로 사용", + "description": "이메일도, 비밀번호도, 개인정보도 필요 없습니다. 원하는 도구를 바로 사용하세요." + }, + "openSource": { + "title": "오픈소스 정신", + "description": "투명성을 바탕으로 만들어졌습니다. PDF-lib, PDF.js 등 뛰어난 오픈소스 라이브러리를 활용하며, 좋은 도구를 누구나 쓸 수 있게 만드는 커뮤니티의 힘을 믿습니다." + } + }, + "cta": { + "title": "시작할 준비가 되셨나요?", + "description": "수많은 사용자가 일상적인 문서 작업에 BentoPDF를 활용하고 있습니다. 프라이버시와 빠른 성능이 가져다주는 차이를 직접 느껴보세요.", + "button": "모든 도구 둘러보기" + } + }, + "contact": { + "title": "문의하기", + "subtitle": "질문, 피드백, 기능 요청 등 무엇이든 편하게 연락해 주세요.", + "email": "아래 이메일로 직접 연락하실 수 있습니다:" + }, + "licensing": { + "title": "라이선스", + "subtitle": "필요에 맞는 라이선스를 선택하세요." + }, + "multiTool": { + "uploadPdfs": "PDF 업로드", + "upload": "업로드", + "addBlankPage": "빈 페이지 추가", + "edit": "편집:", + "undo": "실행 취소", + "redo": "다시 실행", + "reset": "초기화", + "selection": "선택:", + "selectAll": "모두 선택", + "deselectAll": "선택 해제", + "rotate": "회전:", + "rotateLeft": "왼쪽", + "rotateRight": "오른쪽", + "transform": "변환:", + "duplicate": "복제", + "split": "분할", + "clear": "지우기:", + "delete": "삭제", + "download": "다운로드:", + "downloadSelected": "선택 항목 다운로드", + "exportPdf": "PDF 내보내기", + "uploadPdfFiles": "PDF 파일 선택", + "dragAndDrop": "PDF 파일을 여기에 끌어다 놓거나 클릭하여 선택하세요", + "selectFiles": "파일 선택", + "renderingPages": "페이지 렌더링 중...", + "actions": { + "duplicatePage": "이 페이지 복제", + "deletePage": "이 페이지 삭제", + "insertPdf": "이 페이지 뒤에 PDF 삽입", + "toggleSplit": "이 페이지 뒤에서 분할" + }, + "pleaseWait": "잠시만요", + "pagesRendering": "페이지를 렌더링하는 중입니다. 잠시만 기다려 주세요...", + "noPagesSelected": "선택된 페이지 없음", + "selectOnePage": "다운로드할 페이지를 하나 이상 선택해 주세요.", + "noPages": "페이지 없음", + "noPagesToExport": "내보낼 페이지가 없습니다.", + "renderingTitle": "페이지 미리보기 생성 중", + "errorRendering": "페이지 미리보기를 만들지 못했습니다", + "error": "오류", + "failedToLoad": "불러오기 실패" + }, + "simpleMode": { + "title": "PDF 도구", + "subtitle": "사용할 도구를 선택하세요" + } +} diff --git a/public/locales/ko/tools.json b/public/locales/ko/tools.json new file mode 100644 index 0000000..daca6d5 --- /dev/null +++ b/public/locales/ko/tools.json @@ -0,0 +1,671 @@ +{ + "categories": { + "popularTools": "인기 도구", + "editAnnotate": "편집 및 주석", + "convertToPdf": "PDF로 변환", + "convertFromPdf": "PDF에서 변환", + "organizeManage": "정리 및 관리", + "optimizeRepair": "최적화 및 복구", + "securePdf": "PDF 보안" + }, + "pdfMultiTool": { + "name": "PDF 멀티 도구", + "subtitle": "병합, 분할, 정리, 삭제, 회전, 빈 페이지 추가, 추출, 복제를 하나의 화면에서." + }, + "mergePdf": { + "name": "PDF 병합", + "subtitle": "여러 PDF를 하나로 합칩니다. 북마크도 유지됩니다." + }, + "splitPdf": { + "name": "PDF 분할", + "subtitle": "원하는 페이지 범위를 새 PDF로 추출합니다." + }, + "compressPdf": { + "name": "PDF 압축", + "subtitle": "PDF 파일 크기를 줄입니다.", + "algorithmLabel": "압축 알고리즘", + "condense": "Condense (권장)", + "photon": "Photon (사진이 많은 PDF용)", + "condenseInfo": "Condense는 불필요한 데이터 제거, 이미지 최적화, 폰트 서브셋 등 고급 압축을 적용합니다. 대부분의 PDF에 적합합니다.", + "photonInfo": "Photon은 페이지를 이미지로 변환합니다. 사진이 많거나 스캔된 PDF에 적합합니다.", + "photonWarning": "주의: 텍스트 선택이 불가능해지고 링크가 작동하지 않게 됩니다.", + "levelLabel": "압축 수준", + "light": "낮음 (품질 유지)", + "balanced": "보통 (권장)", + "aggressive": "높음 (파일 크기 우선)", + "extreme": "최대 (최고 압축률)", + "grayscale": "흑백으로 변환", + "grayscaleHint": "색상 정보를 제거하여 파일 크기를 줄입니다", + "customSettings": "사용자 설정", + "customSettingsHint": "압축 세부 설정을 조정합니다:", + "outputQuality": "출력 품질", + "resizeImagesTo": "이미지 크기 조정", + "onlyProcessAbove": "이 크기 이상만 처리", + "removeMetadata": "메타데이터 제거", + "subsetFonts": "폰트 서브셋 (미사용 글자 제거)", + "removeThumbnails": "내장 미리보기 이미지 제거", + "compressButton": "PDF 압축하기" + }, + "pdfEditor": { + "name": "PDF 편집기", + "subtitle": "주석, 하이라이트, 마스킹, 댓글, 도형/이미지 추가, 검색 및 보기." + }, + "jpgToPdf": { + "name": "JPG를 PDF로", + "subtitle": "JPG, JPEG, JPEG2000(JP2/JPX) 이미지로 PDF를 만듭니다." + }, + "signPdf": { + "name": "PDF 서명", + "subtitle": "서명을 그리거나, 입력하거나, 이미지로 추가하세요." + }, + "cropPdf": { + "name": "PDF 자르기", + "subtitle": "PDF 모든 페이지의 여백을 잘라냅니다." + }, + "extractPages": { + "name": "페이지 추출", + "subtitle": "원하는 페이지를 골라 새 파일로 저장합니다." + }, + "duplicateOrganize": { + "name": "복제 및 정리", + "subtitle": "페이지를 복제하고, 순서를 바꾸고, 삭제합니다." + }, + "deletePages": { + "name": "페이지 삭제", + "subtitle": "문서에서 특정 페이지를 제거합니다." + }, + "editBookmarks": { + "name": "북마크 편집", + "subtitle": "PDF 북마크를 추가, 편집, 가져오기, 삭제, 추출합니다." + }, + "tableOfContents": { + "name": "목차 생성", + "subtitle": "PDF 북마크를 기반으로 목차 페이지를 만듭니다." + }, + "pageNumbers": { + "name": "페이지 번호", + "subtitle": "문서에 페이지 번호를 삽입합니다." + }, + "batesNumbering": { + "name": "베이츠 번호 매기기", + "subtitle": "하나 이상의 PDF에 순차적 베이츠 번호를 추가합니다." + }, + "addWatermark": { + "name": "워터마크 추가", + "subtitle": "PDF 페이지에 텍스트 또는 이미지 워터마크를 넣습니다.", + "applyToAllPages": "모든 페이지에 적용" + }, + "headerFooter": { + "name": "머리글 및 바닥글", + "subtitle": "페이지 상단과 하단에 텍스트를 추가합니다." + }, + "invertColors": { + "name": "색상 반전", + "subtitle": "PDF를 다크 모드 버전으로 만듭니다." + }, + "scannerEffect": { + "name": "스캔 효과", + "subtitle": "PDF를 스캔한 문서처럼 보이게 만듭니다.", + "scanSettings": "스캔 설정", + "colorspace": "색 공간", + "gray": "흑백", + "border": "테두리", + "rotate": "회전", + "rotateVariance": "회전 변동", + "brightness": "밝기", + "contrast": "대비", + "blur": "흐림", + "noise": "노이즈", + "yellowish": "누런 효과", + "resolution": "해상도", + "processButton": "스캔 효과 적용" + }, + "adjustColors": { + "name": "색상 조정", + "subtitle": "PDF의 밝기, 대비, 채도 등을 세밀하게 조정합니다.", + "colorSettings": "색상 설정", + "brightness": "밝기", + "contrast": "대비", + "saturation": "채도", + "hueShift": "색조", + "temperature": "색온도", + "tint": "틴트", + "gamma": "감마", + "sepia": "세피아", + "processButton": "색상 조정 적용" + }, + "backgroundColor": { + "name": "배경색 변경", + "subtitle": "PDF의 배경색을 변경합니다." + }, + "changeTextColor": { + "name": "텍스트 색상 변경", + "subtitle": "PDF의 텍스트 색상을 변경합니다." + }, + "addStamps": { + "name": "도장 추가", + "subtitle": "주석 도구 모음을 사용하여 PDF에 이미지 도장을 추가합니다.", + "usernameLabel": "도장 사용자 이름", + "usernamePlaceholder": "이름을 입력하세요 (도장용)", + "usernameHint": "이 이름이 도장에 표시됩니다." + }, + "removeAnnotations": { + "name": "주석 제거", + "subtitle": "댓글, 하이라이트, 링크를 제거합니다." + }, + "pdfFormFiller": { + "name": "PDF 양식 작성", + "subtitle": "브라우저에서 직접 양식을 작성합니다. XFA 양식도 지원됩니다." + }, + "createPdfForm": { + "name": "PDF 양식 만들기", + "subtitle": "드래그 앤 드롭으로 작성 가능한 PDF 양식을 만듭니다." + }, + "removeBlankPages": { + "name": "빈 페이지 제거", + "subtitle": "빈 페이지를 자동으로 감지하고 삭제합니다.", + "sensitivityHint": "높을수록 완전히 빈 페이지만 감지합니다. 낮추면 약간의 내용이 있는 페이지도 포함됩니다." + }, + "imageToPdf": { + "name": "이미지를 PDF로", + "subtitle": "JPG, PNG, BMP, GIF, TIFF, PNM, PGM, PBM, PPM, PAM, JXR, JPX, JP2, PSD, SVG, HEIC, WebP를 PDF로 변환합니다." + }, + "pngToPdf": { + "name": "PNG를 PDF로", + "subtitle": "PNG 이미지로 PDF를 만듭니다." + }, + "webpToPdf": { + "name": "WebP를 PDF로", + "subtitle": "WebP 이미지로 PDF를 만듭니다." + }, + "svgToPdf": { + "name": "SVG를 PDF로", + "subtitle": "SVG 이미지로 PDF를 만듭니다." + }, + "bmpToPdf": { + "name": "BMP를 PDF로", + "subtitle": "BMP 이미지로 PDF를 만듭니다." + }, + "heicToPdf": { + "name": "HEIC를 PDF로", + "subtitle": "HEIC 이미지로 PDF를 만듭니다." + }, + "tiffToPdf": { + "name": "TIFF를 PDF로", + "subtitle": "TIFF 이미지로 PDF를 만듭니다." + }, + "textToPdf": { + "name": "텍스트를 PDF로", + "subtitle": "일반 텍스트 파일을 PDF로 변환합니다." + }, + "jsonToPdf": { + "name": "JSON을 PDF로", + "subtitle": "JSON 파일을 PDF 형식으로 변환합니다." + }, + "pdfToJpg": { + "name": "PDF를 JPG로", + "subtitle": "PDF의 각 페이지를 JPG 이미지로 변환합니다." + }, + "pdfToPng": { + "name": "PDF를 PNG로", + "subtitle": "PDF의 각 페이지를 PNG 이미지로 변환합니다." + }, + "pdfToWebp": { + "name": "PDF를 WebP로", + "subtitle": "PDF의 각 페이지를 WebP 이미지로 변환합니다." + }, + "pdfToBmp": { + "name": "PDF를 BMP로", + "subtitle": "PDF의 각 페이지를 BMP 이미지로 변환합니다." + }, + "pdfToTiff": { + "name": "PDF를 TIFF로", + "subtitle": "PDF의 각 페이지를 TIFF 이미지로 변환합니다." + }, + "pdfToGreyscale": { + "name": "PDF 흑백 변환", + "subtitle": "모든 색상을 흑백으로 변환합니다." + }, + "pdfToJson": { + "name": "PDF를 JSON으로", + "subtitle": "PDF 파일을 JSON 형식으로 변환합니다." + }, + "ocrPdf": { + "name": "OCR PDF", + "subtitle": "PDF의 텍스트를 검색하고 복사할 수 있게 만듭니다." + }, + "alternateMix": { + "name": "페이지 교차 병합", + "subtitle": "각 PDF의 페이지를 번갈아 가며 병합합니다. 북마크도 유지됩니다." + }, + "addAttachments": { + "name": "첨부파일 추가", + "subtitle": "PDF에 파일을 첨부합니다." + }, + "extractAttachments": { + "name": "첨부파일 추출", + "subtitle": "PDF에 첨부된 모든 파일을 ZIP으로 추출합니다." + }, + "editAttachments": { + "name": "첨부파일 편집", + "subtitle": "PDF의 첨부파일을 확인하거나 제거합니다." + }, + "dividePages": { + "name": "페이지 분할", + "subtitle": "페이지를 가로 또는 세로로 나눕니다." + }, + "addBlankPage": { + "name": "빈 페이지 추가", + "subtitle": "PDF 원하는 위치에 빈 페이지를 삽입합니다." + }, + "reversePages": { + "name": "페이지 역순 정렬", + "subtitle": "문서의 모든 페이지 순서를 뒤집습니다." + }, + "rotatePdf": { + "name": "PDF 회전", + "subtitle": "페이지를 90도 단위로 회전합니다." + }, + "rotateCustom": { + "name": "사용자 지정 각도 회전", + "subtitle": "원하는 각도로 페이지를 회전합니다." + }, + "nUpPdf": { + "name": "N-Up PDF", + "subtitle": "한 장에 여러 페이지를 배치합니다." + }, + "combineToSinglePage": { + "name": "한 페이지로 합치기", + "subtitle": "모든 페이지를 하나의 긴 페이지로 이어 붙입니다." + }, + "viewMetadata": { + "name": "메타데이터 보기", + "subtitle": "PDF에 숨겨진 속성 정보를 확인합니다." + }, + "editMetadata": { + "name": "메타데이터 편집", + "subtitle": "저자, 제목 등의 속성을 변경합니다." + }, + "pdfsToZip": { + "name": "PDF를 ZIP으로", + "subtitle": "여러 PDF 파일을 ZIP 파일로 묶습니다." + }, + "comparePdfs": { + "name": "PDF 비교", + "subtitle": "두 PDF를 나란히 비교합니다.", + "firstPdf": "첫 번째 PDF", + "secondPdf": "두 번째 PDF", + "clickOrDrop": "클릭 또는 드롭", + "page": "페이지", + "overlay": "오버레이", + "sideBySide": "나란히 보기", + "flicker": "깜빡임", + "syncScroll": "스크롤 동기화", + "export": "내보내기", + "exportAsPdf": "PDF로 내보내기", + "splitView": "분할 보기", + "alternating": "번갈아 보기", + "leftDocument": "왼쪽 문서", + "rightDocument": "오른쪽 문서", + "original": "원본", + "modified": "수정본", + "searchChanges": "변경 사항 검색...", + "deleted": "삭제됨", + "added": "추가됨", + "prevPage": "이전 페이지", + "nextPage": "다음 페이지", + "prevChange": "이전 변경", + "nextChange": "다음 변경", + "uploadTwoPdfs": "차이점을 보려면 두 개의 PDF를 업로드하세요.", + "noDifferences": "이 페이지에서 차이점이 감지되지 않았습니다.", + "noMatchingChanges": "현재 필터와 일치하는 변경 사항이 없습니다.", + "pageNotExist": "페이지 {{page}}는 이 PDF에 존재하지 않습니다.", + "noPairedPage": "이쪽에 대응되는 페이지가 없습니다.", + "buildingModel": "페이지 페어링 모델을 만드는 중...", + "indexingPdf": "PDF {{num}}의 {{page}} / {{total}} 페이지를 인덱싱하는 중...", + "loadingComparison": "비교 {{current}} / {{total}} 불러오는 중...", + "runningOcr": "페이지 {{page}}에서 OCR 실행 중...", + "preparingExport": "PDF 내보내기 준비 중...", + "renderingPage": "페이지 {{current}} / {{total}} 렌더링 중...", + "exportError": "내보내기 오류", + "exportFailed": "비교 PDF를 내보낼 수 없습니다.", + "loadingFile": "{{name}} 불러오는 중...", + "invalidFile": "잘못된 파일", + "invalidFileMsg": "유효한 PDF 파일을 선택하세요.", + "loadError": "PDF를 불러올 수 없습니다. 손상되었거나 비밀번호로 보호되었을 수 있습니다." + }, + "posterizePdf": { + "name": "PDF 포스터화", + "subtitle": "큰 페이지를 여러 작은 페이지로 나눕니다." + }, + "fixPageSize": { + "name": "페이지 크기 통일", + "subtitle": "모든 페이지를 동일한 크기로 맞춥니다." + }, + "linearizePdf": { + "name": "PDF 선형화", + "subtitle": "웹에서 빠르게 볼 수 있도록 PDF를 최적화합니다." + }, + "pageDimensions": { + "name": "페이지 크기 정보", + "subtitle": "페이지 크기, 방향, 단위를 분석합니다." + }, + "removeRestrictions": { + "name": "제한 해제", + "subtitle": "디지털 서명된 PDF의 비밀번호 보호 및 보안 제한을 해제합니다." + }, + "repairPdf": { + "name": "PDF 복구", + "subtitle": "손상된 PDF 파일에서 데이터를 복구합니다." + }, + "encryptPdf": { + "name": "PDF 암호화", + "subtitle": "비밀번호를 설정하여 PDF를 보호합니다." + }, + "sanitizePdf": { + "name": "PDF 정리", + "subtitle": "메타데이터, 주석, 스크립트 등을 제거합니다." + }, + "decryptPdf": { + "name": "PDF 복호화", + "subtitle": "비밀번호를 제거하여 PDF 잠금을 해제합니다." + }, + "flattenPdf": { + "name": "PDF 평탄화", + "subtitle": "양식 필드와 주석을 편집 불가능하게 만듭니다." + }, + "removeMetadata": { + "name": "메타데이터 제거", + "subtitle": "PDF에서 숨겨진 데이터를 삭제합니다." + }, + "changePermissions": { + "name": "권한 변경", + "subtitle": "PDF의 사용자 권한을 설정하거나 변경합니다." + }, + "odtToPdf": { + "name": "ODT를 PDF로", + "subtitle": "ODT(OpenDocument 텍스트) 파일을 PDF로 변환합니다. 여러 파일 지원.", + "acceptedFormats": "ODT 파일", + "convertButton": "PDF로 변환" + }, + "csvToPdf": { + "name": "CSV를 PDF로", + "subtitle": "CSV 스프레드시트 파일을 PDF로 변환합니다. 여러 파일 지원.", + "acceptedFormats": "CSV 파일", + "convertButton": "PDF로 변환" + }, + "rtfToPdf": { + "name": "RTF를 PDF로", + "subtitle": "RTF(서식 있는 텍스트) 문서를 PDF로 변환합니다. 여러 파일 지원.", + "acceptedFormats": "RTF 파일", + "convertButton": "PDF로 변환" + }, + "wordToPdf": { + "name": "Word를 PDF로", + "subtitle": "Word 문서(DOCX, DOC, ODT, RTF)를 PDF로 변환합니다. 여러 파일 지원.", + "acceptedFormats": "DOCX, DOC, ODT, RTF 파일", + "convertButton": "PDF로 변환" + }, + "excelToPdf": { + "name": "Excel을 PDF로", + "subtitle": "Excel 스프레드시트(XLSX, XLS, ODS, CSV)를 PDF로 변환합니다. 여러 파일 지원.", + "acceptedFormats": "XLSX, XLS, ODS, CSV 파일", + "convertButton": "PDF로 변환" + }, + "powerpointToPdf": { + "name": "PowerPoint를 PDF로", + "subtitle": "PowerPoint 프레젠테이션(PPTX, PPT, ODP)을 PDF로 변환합니다. 여러 파일 지원.", + "acceptedFormats": "PPTX, PPT, ODP 파일", + "convertButton": "PDF로 변환" + }, + "markdownToPdf": { + "name": "Markdown을 PDF로", + "subtitle": "Markdown을 작성하거나 붙여넣고 깔끔하게 서식이 적용된 PDF로 내보냅니다.", + "paneMarkdown": "Markdown", + "panePreview": "미리보기", + "btnUpload": "업로드", + "btnSyncScroll": "스크롤 동기화", + "btnSettings": "설정", + "btnExportPdf": "PDF 내보내기", + "settingsTitle": "Markdown 설정", + "settingsPreset": "프리셋", + "presetDefault": "기본 (GFM 스타일)", + "presetCommonmark": "CommonMark (엄격)", + "presetZero": "최소 (기능 없음)", + "settingsOptions": "Markdown 옵션", + "optAllowHtml": "HTML 태그 허용", + "optBreaks": "줄바꿈을
로 변환", + "optLinkify": "URL을 자동으로 링크로 변환", + "optTypographer": "타이포그래퍼 (스마트 따옴표 등)" + }, + "pdfBooklet": { + "name": "PDF 소책자", + "subtitle": "양면 인쇄용으로 페이지를 재배치합니다. 접고 스테이플러로 철하면 소책자가 됩니다.", + "howItWorks": "이용 방법:", + "step1": "PDF 파일을 업로드합니다.", + "step2": "페이지가 소책자 순서로 재배치됩니다.", + "step3": "양면 인쇄 후 짧은 면을 기준으로 접어 철합니다.", + "paperSize": "용지 크기", + "orientation": "방향", + "portrait": "세로", + "landscape": "가로", + "pagesPerSheet": "한 면당 페이지 수", + "createBooklet": "소책자 만들기", + "processing": "처리 중...", + "pageCount": "필요 시 페이지 수가 4의 배수로 맞춰집니다." + }, + "xpsToPdf": { + "name": "XPS를 PDF로", + "subtitle": "XPS/OXPS 문서를 PDF로 변환합니다. 여러 파일 지원.", + "acceptedFormats": "XPS, OXPS 파일", + "convertButton": "PDF로 변환" + }, + "mobiToPdf": { + "name": "MOBI를 PDF로", + "subtitle": "MOBI 전자책을 PDF로 변환합니다. 여러 파일 지원.", + "acceptedFormats": "MOBI 파일", + "convertButton": "PDF로 변환" + }, + "epubToPdf": { + "name": "EPUB를 PDF로", + "subtitle": "EPUB 전자책을 PDF로 변환합니다. 여러 파일 지원.", + "acceptedFormats": "EPUB 파일", + "convertButton": "PDF로 변환" + }, + "fb2ToPdf": { + "name": "FB2를 PDF로", + "subtitle": "FictionBook(FB2) 전자책을 PDF로 변환합니다. 여러 파일 지원.", + "acceptedFormats": "FB2 파일", + "convertButton": "PDF로 변환" + }, + "cbzToPdf": { + "name": "CBZ를 PDF로", + "subtitle": "만화 아카이브(CBZ/CBR)를 PDF로 변환합니다. 여러 파일 지원.", + "acceptedFormats": "CBZ, CBR 파일", + "convertButton": "PDF로 변환" + }, + "wpdToPdf": { + "name": "WPD를 PDF로", + "subtitle": "WordPerfect(WPD) 문서를 PDF로 변환합니다. 여러 파일 지원.", + "acceptedFormats": "WPD 파일", + "convertButton": "PDF로 변환" + }, + "wpsToPdf": { + "name": "WPS를 PDF로", + "subtitle": "WPS Office 문서를 PDF로 변환합니다. 여러 파일 지원.", + "acceptedFormats": "WPS 파일", + "convertButton": "PDF로 변환" + }, + "xmlToPdf": { + "name": "XML을 PDF로", + "subtitle": "XML 문서를 PDF로 변환합니다. 여러 파일 지원.", + "acceptedFormats": "XML 파일", + "convertButton": "PDF로 변환" + }, + "pagesToPdf": { + "name": "Pages를 PDF로", + "subtitle": "Apple Pages 문서를 PDF로 변환합니다. 여러 파일 지원.", + "acceptedFormats": "Pages 파일", + "convertButton": "PDF로 변환" + }, + "odgToPdf": { + "name": "ODG를 PDF로", + "subtitle": "ODG(OpenDocument 그래픽) 파일을 PDF로 변환합니다. 여러 파일 지원.", + "acceptedFormats": "ODG 파일", + "convertButton": "PDF로 변환" + }, + "odsToPdf": { + "name": "ODS를 PDF로", + "subtitle": "ODS(OpenDocument 스프레드시트) 파일을 PDF로 변환합니다. 여러 파일 지원.", + "acceptedFormats": "ODS 파일", + "convertButton": "PDF로 변환" + }, + "odpToPdf": { + "name": "ODP를 PDF로", + "subtitle": "ODP(OpenDocument 프레젠테이션) 파일을 PDF로 변환합니다. 여러 파일 지원.", + "acceptedFormats": "ODP 파일", + "convertButton": "PDF로 변환" + }, + "pubToPdf": { + "name": "PUB를 PDF로", + "subtitle": "Microsoft Publisher(PUB) 파일을 PDF로 변환합니다. 여러 파일 지원.", + "acceptedFormats": "PUB 파일", + "convertButton": "PDF로 변환" + }, + "vsdToPdf": { + "name": "VSD를 PDF로", + "subtitle": "Microsoft Visio(VSD, VSDX) 파일을 PDF로 변환합니다. 여러 파일 지원.", + "acceptedFormats": "VSD, VSDX 파일", + "convertButton": "PDF로 변환" + }, + "psdToPdf": { + "name": "PSD를 PDF로", + "subtitle": "Adobe Photoshop(PSD) 파일을 PDF로 변환합니다. 여러 파일 지원.", + "acceptedFormats": "PSD 파일", + "convertButton": "PDF로 변환" + }, + "pdfToSvg": { + "name": "PDF를 SVG로", + "subtitle": "PDF의 각 페이지를 SVG(벡터 그래픽)로 변환하여 어떤 크기에서도 선명하게 볼 수 있습니다." + }, + "extractTables": { + "name": "PDF 표 추출", + "subtitle": "PDF에서 표를 추출하여 CSV, JSON, Markdown으로 내보냅니다." + }, + "pdfToCsv": { + "name": "PDF를 CSV로", + "subtitle": "PDF에서 표를 추출하여 CSV로 변환합니다." + }, + "pdfToExcel": { + "name": "PDF를 Excel로", + "subtitle": "PDF에서 표를 추출하여 Excel(XLSX)로 변환합니다." + }, + "pdfToText": { + "name": "PDF를 텍스트로", + "subtitle": "PDF에서 텍스트를 추출하여 텍스트 파일(.txt)로 저장합니다. 여러 파일 지원.", + "note": "이 도구는 디지털로 생성된 PDF에서만 작동합니다. 스캔 문서나 이미지 기반 PDF는 OCR PDF 도구를 사용해 주세요.", + "convertButton": "텍스트 추출" + }, + "digitalSignPdf": { + "name": "PDF 디지털 서명", + "pageTitle": "PDF 디지털 서명 - 암호화 서명 추가 | BentoPDF", + "subtitle": "X.509 인증서를 사용하여 PDF에 암호화 디지털 서명을 추가합니다. PKCS#12(.pfx, .p12) 및 PEM 형식을 지원합니다. 개인키는 브라우저 밖으로 나가지 않습니다.", + "certificateSection": "인증서", + "uploadCert": "인증서 업로드 (.pfx, .p12)", + "certPassword": "인증서 비밀번호", + "certPasswordPlaceholder": "인증서 비밀번호 입력", + "certInfo": "인증서 정보", + "certSubject": "소유자", + "certIssuer": "발급자", + "certValidity": "유효 기간", + "signatureDetails": "서명 세부 정보 (선택 사항)", + "reason": "서명 사유", + "reasonPlaceholder": "예: 이 문서를 승인합니다", + "location": "위치", + "locationPlaceholder": "예: 서울, 대한민국", + "contactInfo": "연락처", + "contactPlaceholder": "예: email@example.com", + "applySignature": "디지털 서명 적용", + "successMessage": "PDF 서명이 완료되었습니다! 모든 PDF 뷰어에서 서명을 확인할 수 있습니다." + }, + "validateSignaturePdf": { + "name": "PDF 서명 검증", + "pageTitle": "PDF 서명 검증 - 디지털 서명 확인 | BentoPDF", + "subtitle": "PDF의 디지털 서명을 검증합니다. 인증서 유효성 확인, 서명자 정보 조회, 문서 무결성 확인이 가능합니다. 모든 처리는 브라우저에서 이루어집니다." + }, + "emailToPdf": { + "name": "이메일을 PDF로", + "subtitle": "이메일 파일(EML, MSG)을 PDF로 변환합니다. Outlook 내보내기 및 표준 이메일 형식을 지원합니다.", + "acceptedFormats": "EML, MSG 파일", + "convertButton": "PDF로 변환" + }, + "fontToOutline": { + "name": "폰트 윤곽선 변환", + "subtitle": "모든 폰트를 벡터 윤곽선으로 변환하여 어떤 기기에서든 동일하게 표시됩니다." + }, + "deskewPdf": { + "name": "PDF 기울기 보정", + "subtitle": "기울어진 스캔 페이지를 OpenCV로 자동 보정합니다." + }, + "pdfToWord": { + "name": "PDF를 Word로", + "subtitle": "PDF를 편집 가능한 Word 문서로 변환합니다." + }, + "extractImages": { + "name": "이미지 추출", + "subtitle": "PDF에 포함된 모든 이미지를 추출합니다." + }, + "pdfToMarkdown": { + "name": "PDF를 Markdown으로", + "subtitle": "PDF의 텍스트와 표를 Markdown 형식으로 변환합니다." + }, + "preparePdfForAi": { + "name": "AI용 PDF 준비", + "subtitle": "RAG/LLM 파이프라인을 위해 PDF를 LlamaIndex JSON으로 추출합니다." + }, + "pdfOcg": { + "name": "PDF OCG", + "subtitle": "PDF의 OCG(Optional Content Group) 레이어를 보고, 전환하고, 추가하고, 삭제합니다." + }, + "pdfToPdfa": { + "name": "PDF를 PDF/A로", + "subtitle": "장기 보관을 위해 PDF를 PDF/A로 변환합니다." + }, + "rasterizePdf": { + "name": "PDF 래스터화", + "subtitle": "PDF를 이미지 기반 PDF로 변환합니다. 레이어를 평탄화하고 선택 가능한 텍스트를 제거합니다." + }, + "pdfWorkflow": { + "name": "PDF 워크플로우 빌더", + "subtitle": "시각적 노드 편집기로 맞춤형 PDF 처리 파이프라인을 구성합니다.", + "nodes": "노드", + "searchNodes": "노드 검색...", + "run": "실행", + "clear": "지우기", + "save": "저장", + "load": "불러오기", + "export": "내보내기", + "import": "가져오기", + "ready": "준비 완료", + "settings": "설정", + "processing": "처리 중...", + "saveTemplate": "템플릿 저장", + "templateName": "템플릿 이름", + "templatePlaceholder": "예: 청구서 워크플로우", + "cancel": "취소", + "loadTemplate": "템플릿 불러오기", + "noTemplates": "저장된 템플릿이 없습니다.", + "ok": "확인", + "workflowCompleted": "워크플로우가 완료되었습니다", + "errorDuringExecution": "실행 중 오류 발생", + "addNodeError": "워크플로우를 실행하려면 노드를 하나 이상 추가하세요.", + "needInputOutput": "워크플로우를 실행하려면 입력 노드와 출력 노드가 각각 하나 이상 필요합니다.", + "enterName": "이름을 입력해 주세요.", + "templateExists": "같은 이름의 템플릿이 이미 있습니다.", + "templateSaved": "템플릿 \"{{name}}\"이(가) 저장되었습니다.", + "templateLoaded": "템플릿 \"{{name}}\"을(를) 불러왔습니다.", + "failedLoadTemplate": "템플릿을 불러오지 못했습니다.", + "noSettings": "이 노드에는 설정할 항목이 없습니다.", + "advancedSettings": "고급 설정" + } +} diff --git a/public/locales/nl/common.json b/public/locales/nl/common.json new file mode 100644 index 0000000..caa6256 --- /dev/null +++ b/public/locales/nl/common.json @@ -0,0 +1,365 @@ +{ + "nav": { + "home": "Thuis", + "about": "Over", + "contact": "Contact", + "licensing": "Licentie", + "allTools": "Alle Tools", + "openMainMenu": "Hoofdmenu openen", + "language": "Taal" + }, + "donation": { + "message": "Vind je BentoPDF geweldig? Help ons het gratis en open source te houden!", + "button": "Donatie" + }, + "hero": { + "title": "De", + "pdfToolkit": "PDF Toolkit", + "builtForPrivacy": "gemaakt voor privacy", + "noSignups": "Geen aanmelding", + "unlimitedUse": "Onbegrenst gebruik", + "worksOffline": "Werkt offline", + "startUsing": "Direct aan de slag" + }, + "usedBy": { + "title": "Gebruikt door bedrijven en personen werkzaam bij" + }, + "features": { + "title": "Waarom", + "bentoPdf": "BentoPDF?", + "noSignup": { + "title": "Geen aanmelding", + "description": "Direct aan de slag, zonder account of e-mails." + }, + "noUploads": { + "title": "Geen uploads", + "description": "100% client-side, je bestanden blijven op jouw apparaat." + }, + "foreverFree": { + "title": "Voor altijd gratis", + "description": "Alle tools, geen proef, geen betaalmuur." + }, + "noLimits": { + "title": "Geen beperkingen", + "description": "Gebruik het zoveel je wilt, geen verborgen beperkingen." + }, + "batchProcessing": { + "title": "Reeksen verwerken", + "description": "Verwerk in een keer een onbeperkt aantal PDF's." + }, + "lightningFast": { + "title": "Bliksemsnel", + "description": "Verwerk PDF's direct, zonder wachten of vertraging." + } + }, + "tools": { + "title": "Aan de slag met", + "toolsLabel": "Tools", + "subtitle": "Klik een tool om de bestandslader te openen", + "searchPlaceholder": "Zoek een tool (bijv. 'splitsen', 'organiseren', ...)", + "backToTools": "Terug naar Tools", + "firstLoadNotice": "De eerste keer duurt het even om onze machinerie te laden. Daarna gaat laadt alles direct." + }, + "upload": { + "clickToSelect": "Klik om een bestand te selecteren", + "orDragAndDrop": "of sleep er een hierheen", + "pdfOrImages": "PDF's of afbeeldingen", + "filesNeverLeave": "Je bestanden blijven op jouw apparaat.", + "addMore": "Meer bestanden toevoegen", + "clearAll": "Alles wissen", + "clearFiles": "Bestanden wissen", + "hints": { + "singlePdf": "Eén enkel PDF-bestand", + "pdfFile": "PDF-bestand", + "multiplePdfs2": "Meerdere PDF-bestanden (minimaal 2)", + "bmpImages": "BMP-afbeeldingen", + "oneOrMorePdfs": "Eén of meer PDF-bestanden", + "pdfDocuments": "PDF-documenten", + "oneOrMoreCsv": "Eén of meer CSV-bestanden", + "multiplePdfsSupported": "Meerdere PDF-bestanden ondersteund", + "singleOrMultiplePdfs": "Enkel of meerdere PDF-bestanden ondersteund", + "singlePdfFile": "Enkel PDF-bestand", + "pdfWithForms": "PDF-bestand met formuliervelden", + "heicImages": "HEIC/HEIF-afbeeldingen", + "jpgImages": "JPG, JPEG, JP2, JPX afbeeldingen", + "pdfsOrImages": "PDF's of afbeeldingen", + "oneOrMoreOdt": "Eén of meer ODT-bestanden", + "singlePdfOnly": "Alleen een enkel PDF-bestand", + "pdfFiles": "PDF-bestanden", + "multiplePdfs": "Meerdere PDF-bestanden", + "pngImages": "PNG-afbeeldingen", + "pdfFilesOneOrMore": "PDF-bestanden (een of meer)", + "oneOrMoreRtf": "Eén of meer RTF-bestanden", + "svgGraphics": "SVG-afbeeldingen", + "tiffImages": "TIFF-afbeeldingen", + "webpImages": "WebP-afbeeldingen" + } + }, + "howItWorks": { + "title": "Hoe het werkt", + "step1": "Klik of sleep uw bestand hierheen", + "step2": "Klik op de verwerkingsknop", + "step3": "Sla uw verwerkte bestand direct op" + }, + "relatedTools": { + "title": "Gerelateerde PDF-tools" + }, + "loader": { + "processing": "Verwerken..." + }, + "alert": { + "title": "Let op", + "ok": "OK" + }, + "preview": { + "title": "Document weergeven", + "downloadAsPdf": "Downloaden als PDF", + "close": "Sluiten" + }, + "settings": { + "title": "Instellingen", + "shortcuts": "Sneltoetsen", + "preferences": "Voorkeuren", + "displayPreferences": "Voorkeuren weergeven", + "searchShortcuts": "Sneltoetsen zoeken...", + "shortcutsInfo": "Houd toetsen ingedrukt om deze in te stellen als sneltoets. Aanpassingen worden automatisch opgeslagen.", + "shortcutsWarning": "⚠️ Vermijd algemene sneltoetsen voor browsers (Cmd/Ctrl+W, Cmd/Ctrl+T, Cmd/Ctrl+N etc.) aangezien deze niet betrouwbaar kunnen functioneren.", + "import": "Importeren", + "export": "Exporteren", + "resetToDefaults": "Terugzetten naar standaard", + "fullWidthMode": "Volledige breedte", + "fullWidthDescription": "Gebruik de volledige breedte van het scherm in plaats van een gecentreerde kolom", + "settingsAutoSaved": "Instellingen worden automatisch opgeslagen", + "clickToSet": "Klik om in te stellen", + "pressKeys": "Druk toetsen...", + "warnings": { + "alreadyInUse": "Sneltoets al in gebruik", + "assignedTo": "is al toegewezen aan:", + "chooseDifferent": "Kies een andere sneltoets.", + "reserved": "Waarschuwing voor gereserveerde sneltoets", + "commonlyUsed": "wordt algemeen gebruikt voor:", + "unreliable": "Deze sneltoets werkt mogelijk niet goed of is in conflict met gedrag van browser of systeem.", + "useAnyway": "Wil je het toch gebruiken?", + "resetTitle": "Sneltoetsen terugzetten", + "resetMessage": "Weet je zeker dat je alle sneltoetsen wilt terugzetten naar standaard?

Deze actie kan niet ongedaan worden gemaakt.", + "importSuccessTitle": "Import voltooid", + "importSuccessMessage": "Sneltoetsen zijn met succes geïmporteerd!", + "importFailTitle": "Import mislukt", + "importFailMessage": "Het importeren van sneltoetsen is mislukt. Ongeldig bestandsformaat." + } + }, + "warning": { + "title": "Waarschuwing", + "cancel": "Annuleren", + "proceed": "Verder" + }, + "compliance": { + "title": "Jouw gegevens blijven op jouw appraat", + "weKeep": "Wij houden", + "yourInfoSafe": "jouw informatie veilig", + "byFollowingStandards": "volgens de volgende algemene veiligheidsstandaarden.", + "processingLocal": "Alle verwerking vindt lokaal plaats op jouw apparaat.", + "gdpr": { + "title": "AVG-naleving", + "description": "Beschermt persoonlijke gegevens en privacy van individuën binnen de Europese Unie." + }, + "ccpa": { + "title": "CCPA-naleving", + "description": "Geeft inwoners van Californië rechten over hoe hun persoonlijke gegevens worden verzameld, gebruikt en gedeeld." + }, + "hipaa": { + "title": "HIPAA-naleving", + "description": "Stelt waarborgen in voor het omgaan met gevoelige gezondheidsinformatie in het gezondheidszorgsysteem van de Verenigde Staten." + } + }, + "faq": { + "title": "Veelgestelde", + "questions": "Vragen", + "sectionTitle": "Veelgestelde vragen", + "isFree": { + "question": "Is BentoPDF echt gratis?", + "answer": "Ja, absoluut. Alle tools op BentoPDF zijn 100% gratis te gebruiken, zonder bestandslimieten, zonder aanmeldingen en zonder watermerken. Wij vinden dat iedereen toegang verdient tot eenvoudige, krachtige PDF-tools zonder betaalmuur." + }, + "areFilesSecure": { + "question": "Zijn mijn bestanden veilig? Waar worden ze verwerkt?", + "answer": "Je bestanden zijn zo veilig mogelijk omdat ze je computer nooit verlaten. Alle verwerking gebeurt direct in je webbrowser (client-side). Je bestanden worden nooit naar een server ge-upload, zodat je volledige privacy en controle over je documenten behoudt." + }, + "platforms": { + "question": "Werkt het op Mac, Windows en mobiel?", + "answer": "Ja! Omdat BentoPDF volledig in je browser werkt, werkt het op elk besturingssysteem met een moderne webbrowser, inclusief Windows, macOS, Linux, iOS en Android." + }, + "gdprCompliant": { + "question": "Is BentoPDF AVG-conform?", + "answer": "Ja. BentoPDF voldoet volledig aan de AVG. Omdat alle bestandsverwerking lokaal in je browser gebeurt en we nooit je bestanden naar een server sturen of verzamelen, hebben wij geen toegang tot je gegevens. Zo houd jij altijd de controle over je documenten." + }, + "dataStorage": { + "question": "Slaan jullie mijn bestanden op of volgen jullie die?", + "answer": "Nee. We slaan je bestanden nooit op, volgen ze niet en houden er geen logboek van bij. Alles wat je op BentoPDF doet, gebeurt in het geheugen van je browser en verdwijnt zodra je de pagina sluit. Er is geen upload, geen geschiedenislogboek en geen servers bij betrokken." + }, + "different": { + "question": "Wat maakt BentoPDF anders dan andere PDF-tools?", + "answer": "De meeste PDF-tools verlangen dat je je bestanden voor verwerking naar een server uploadt. BentoPDF doet dat nooit. Wij gebruiken veilige, moderne webtechnologie om je bestanden direct in je browser te verwerken. Dit betekent snellere prestaties, betere privacy en totale gemoedsrust." + }, + "browserBased": { + "question": "Hoe zorgt browsergebaseerde verwerking ervoor dat ik veilig blijf?", + "answer": "Omdat BentoPDF helemaal in je browser draait, blijven je bestanden altijd op je apparaat. Daardoor hoef je je geen zorgen te maken over hacks, datalekken of ongeoorloofde toegang. Je bestanden zijn altijd van jou." + }, + "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." + } + }, + "testimonials": { + "title": "Wat onze", + "users": "Gebruikers", + "say": "Vertellen" + }, + "support": { + "title": "Vind je mijn werk leuk?", + "description": "BentoPDF is een project van passie, gemaakt om iedereen een gratis, privé en krachtig PDF-gereedschap te bieden. Als je het handig vindt, overweeg dan om de ontwikkeling te steunen. Elke koffie helpt!", + "buyMeCoffee": "Koop een kopje koffie voor me" + }, + "footer": { + "copyright": "© 2026 BentoPDF. Alle rechten voorbehouden.", + "version": "Versie", + "company": "Bedrijf", + "aboutUs": "Over ons", + "faqLink": "Veelgestelde vragen", + "contactUs": "Contact", + "legal": "Juridisch", + "termsAndConditions": "Algemene voorwaarden", + "privacyPolicy": "Privacybeleid", + "followUs": "Volgen" + }, + "merge": { + "title": "PDF's samenvoegen", + "description": "Combineer hele bestanden, of selecteer specifieke pagina's om te samen te voegen tot een nieuw document.", + "fileMode": "Bestandsmodus", + "pageMode": "Paginamodus", + "howItWorks": "Hoe het werkt:", + "fileModeInstructions": [ + "Klik en sleep het pictogram om de volgorde van de bestanden te wijzigen.", + "In het vak \"Pagina's\" voor elk bestand kan je reeksen opgeven (bijv. \"1-3, 5\") om alleen die pagina's samen te voegen.", + "Laat het vak \"Pagina's\" leeg om alle pagina's in het bestand op te nemen." + ], + "pageModeInstructions": [ + "Alle pagina's van je PDF's worden hieronder weergegeven.", + "Sleep afzonderlijke pagina-miniaturen in de gewenste volgorde voor je nieuwe bestand." + ], + "mergePdfs": "PDF's samenvoegen" + }, + "common": { + "page": "Pagina", + "pages": "Pagina's", + "of": "van", + "download": "Downloaden", + "cancel": "Annuleren", + "save": "Opslaan", + "delete": "Verwijderen", + "edit": "Bewerken", + "add": "Toevoegen", + "remove": "Verwijderen", + "loading": "Laden...", + "error": "Fout", + "success": "Succes", + "file": "Bestand", + "files": "Bestanden", + "close": "Sluiten" + }, + "about": { + "hero": { + "title": "Wij vinden dat PDF-tools", + "subtitle": "snel, privé en gratis moeten zijn.", + "noCompromises": "Geen compromissen." + }, + "mission": { + "title": "Onze missie", + "description": "Ons doel is om de meest complete PDF-toolbox te bieden die je privacy respecteert en nooit om betaling vraagt. Wij geloven dat essentiële documententools voor iedereen, overal en zonder obstakels toegankelijk moeten zijn." + }, + "philosophy": { + "label": "Onze kernfilosofie", + "title": "Privacy First. Altijd.", + "description": "In een tijdperk waarin data een handelswaar is, doen wij het net even anders. Alle verwerking voor BentoPDF-tools gebeurt lokaal in je browser. Dat betekent dat je bestanden nooit oop onze servers terechtkomen, dat wij je documenten nooit zien en niets volgen van wat je doet. Je documenten blijven volledig privé, punt. Het is niet zomaar een functie; het is onze basis." + }, + "whyBentopdf": { + "title": "Waarom", + "speed": { + "title": "Gemaakt voor snelheid", + "description": "Geen wachttijd voor uploads of downloads naar een server. Door bestanden direct in je browser te verwerken met moderne webtechnologieën zoals WebAssembly, bieden we ongeëvenaarde snelheid voor al onze tools." + }, + "free": { + "title": "Volledig gratis", + "description": "Geen proefversies, geen abonnementen, geen verborgen kosten en geen \"premium\" functies die vworden achtergehouden. Wij vinden dat krachtige PDF-tools een openbare dienst moeten zijn, geen winstmachine." + }, + "noAccount": { + "title": "Geen account vereist", + "description": "Begin meteen met het gebruiken van een tool. Je e-mail, wachtwoord of persoonlijke gegevens hebben we niet nodig. Je workflow kan soepel en anoniem zijn." + }, + "openSource": { + "title": "De geest van Open Source", + "description": "Gebouwd met transparantie in gedachten. We maken gebruik van geweldige open-source bibliotheken zoals PDF-lib en PDF.js en geloven in de communitygedreven inspanning om krachtige tools voor iedereen toegankelijk te maken." + } + }, + "cta": { + "title": "Klaar om te beginnen?", + "description": "Sluit je aan bij duizenden gebruikers die BentoPDF vertrouwen voor hun dagelijkse documentbehoeften. Ervaar zelf het verschil dat privacy en prestaties kunnen maken.", + "button": "Ontdek alle tools" + } + }, + "contact": { + "title": "Neem contact op", + "subtitle": "We horen graag van je. Of je nu een vraag, feedback of een verzoek voor een functie hebt, aarzel dan niet om contact met ons op te nemen.", + "email": "Je kunt ons rechtstreeks bereiken via e-mail op:" + }, + "licensing": { + "title": "Licentie voor", + "subtitle": "Kies de licentie die bij je past." + }, + "multiTool": { + "uploadPdfs": "PDF's laden", + "upload": "Laden", + "addBlankPage": "Blanco pagina toevoegen", + "edit": "Bewerken:", + "undo": "Ongedaan maken", + "redo": "Opnieuw", + "reset": "Terugzetten", + "selection": "Selectie:", + "selectAll": "Alles selecteren", + "deselectAll": "Selectie opheffen", + "rotate": "Roteren:", + "rotateLeft": "Linksom", + "rotateRight": "Rechtsom", + "transform": "Transformeren:", + "duplicate": "Dupliceren", + "split": "Splitsen", + "clear": "Wissen:", + "delete": "Verwijderen", + "download": "Laden:", + "downloadSelected": "Selectie laden", + "exportPdf": "PDF exporteren", + "uploadPdfFiles": "PDF-bestanden selecteren", + "dragAndDrop": "Klik om een bestand te selecteren, of sleep er een hierheen", + "selectFiles": "Bestanden selecteren", + "renderingPages": "Pagina's vewerken...", + "actions": { + "duplicatePage": "Deze pagina dupliceren", + "deletePage": "Deze pagina verwijderen", + "insertPdf": "PDF achter deze pagina invoegen", + "toggleSplit": "Splitsing maken na deze pagina" + }, + "pleaseWait": "Even geduld", + "pagesRendering": "Pagina's worden nog verwerkt. Even geduld...", + "noPagesSelected": "Geen pagina's geselecteerd", + "selectOnePage": "Kies tenminste een pagina om te laden.", + "noPages": "Geen pagina's", + "noPagesToExport": "Er zijn geen pagina's om te exporteren.", + "renderingTitle": "Paginavoorbeeld verwerken", + "errorRendering": "Generatie van pagina-miniaturen is mislukt", + "error": "Fout", + "failedToLoad": "Laden is mislukt" + }, + "simpleMode": { + "title": "PDF-tools", + "subtitle": "Selecteer een tool om te beginnen" + } +} diff --git a/public/locales/nl/tools.json b/public/locales/nl/tools.json new file mode 100644 index 0000000..40a1630 --- /dev/null +++ b/public/locales/nl/tools.json @@ -0,0 +1,671 @@ +{ + "categories": { + "popularTools": "Populaire Tools", + "editAnnotate": "Bewerken & Annoteren", + "convertToPdf": "Converteren naar PDF", + "convertFromPdf": "Converteren van PDF", + "organizeManage": "Organiseren & Beheren", + "optimizeRepair": "Optimaliseren & Repareren", + "securePdf": "PDF beveiligen" + }, + "pdfMultiTool": { + "name": "PDF Multi-tool", + "subtitle": "Samenvoegen, Splitsen, Organiseren, Verwijderen, Roteren, Blanco pagina's toevoegen, Extraheren and Dupliceren in één enkele werkomgeving." + }, + "mergePdf": { + "name": "PDF Samenvoegen", + "subtitle": "Meerdere PDF's combineren tot één bestand. Bladwijzers behouden." + }, + "splitPdf": { + "name": "PDF Splitsen", + "subtitle": "Een reeks pagina's opslaan in een nieuwe PDF." + }, + "compressPdf": { + "name": "PDF Comprimeren", + "subtitle": "De bestandsgrootte van een PDF verkleinen.", + "algorithmLabel": "Compressie-algoritme", + "condense": "Condense (Aanbevolen)", + "photon": "Photon (Voor PDF's met veel foto's)", + "condenseInfo": "Condense gebruikt geavanceerde compressie: verwijdert overbodige onderdelen, optimaliseert afbeeldingen en kiest alleen de benodigde letters uit fonts. Ideaal voor de meeste PDF's.", + "photonInfo": "Photon zet pagina's om in afbeeldingen. Handig voor PDF's met veel foto's of gescande documenten.", + "photonWarning": "Let op: De tekst kan dan niet meer geselecteerd worden en links werken niet meer.", + "levelLabel": "Compressieniveau", + "light": "Licht (Kwaliteit behouden)", + "balanced": "Gebalanceerd (Aanbevolen)", + "aggressive": "Agressief (Kleinere bestanden)", + "extreme": "Extreem (Maximale compressie)", + "grayscale": "Converteren naar grijswaarden", + "grayscaleHint": "Vermindert de bestandsgrootte door kleurinformatie te verwijderen", + "customSettings": "Aangepaste instellingen", + "customSettingsHint": "Compressie-instellingen verfijnen:", + "outputQuality": "Uitvoerkwaliteit", + "resizeImagesTo": "Formaat van afbeeldingen aanpassen naar", + "onlyProcessAbove": "Alleen verwerken boven", + "removeMetadata": "Metagegevens wissen", + "subsetFonts": "Subset-lettertypen (ongebruikte tekens verwijderen)", + "removeThumbnails": "Ingesloten miniaturen verwijderen", + "compressButton": "PDF Comprimeren" + }, + "pdfEditor": { + "name": "PDF Bewerken", + "subtitle": "PDF's annoteren, markeren, redigeren, commentaar toevoegen, vormen/afbeelding toevoegen, doorzoeken and weergeven." + }, + "jpgToPdf": { + "name": "JPG naar PDF", + "subtitle": "Een of meer JPG-afbeeldingen opslaan als PDF." + }, + "signPdf": { + "name": "PDF Ondertekenen", + "subtitle": "Een handtekening tekenen, typen, of invoegen." + }, + "cropPdf": { + "name": "PDF Bijsnijden", + "subtitle": "De marges aanpassen van alle pagina's in een PDF." + }, + "extractPages": { + "name": "Pagina's Extraheren", + "subtitle": "Een selectie van pagina's opslaan als nieuw bestand." + }, + "duplicateOrganize": { + "name": "Dupliceren & Organiseren", + "subtitle": "Pagina's dupliceren, ordenen en verwijderen." + }, + "deletePages": { + "name": "Pagina's Verwijderen", + "subtitle": "Specifieke pagina's uit een dodocument verwijderen." + }, + "editBookmarks": { + "name": "Bladwijzers Bewerken", + "subtitle": "PDF-bladwijzers toevoegen, bewerken, importeren, verwijderen and extraheren." + }, + "tableOfContents": { + "name": "Inhoudsopgave", + "subtitle": "Een inhoudsopgave genereren van PDF-bladwijzers." + }, + "pageNumbers": { + "name": "Paginanummers", + "subtitle": "Paginanummers aan een document toevoegen." + }, + "batesNumbering": { + "name": "Bates-nummering", + "subtitle": "Opeenvolgende Bates-nummering toevoegen aan een of meer PDF-bestanden." + }, + "addWatermark": { + "name": "Watermerk toevoegen", + "subtitle": "Tekst of een afbeelding over de pagina's van een PDF stempelen.", + "applyToAllPages": "Op alle pagina's toepassen" + }, + "headerFooter": { + "name": "Koptekst & Voettekst", + "subtitle": "Tekst boven- of onderaan de pagina's toevoegen." + }, + "invertColors": { + "name": "Kleuren Omkeren", + "subtitle": "Een \"donkere modus\"-versie van een PDF maken." + }, + "scannerEffect": { + "name": "Scannereffect", + "subtitle": "Laat een PDF eruitzien als een gescand document.", + "scanSettings": "Scaninstellingen", + "colorspace": "Kleurruimte", + "gray": "Grijswaarden", + "border": "Rand", + "rotate": "Roteren", + "rotateVariance": "Rotatievariatie", + "brightness": "Helderheid", + "contrast": "Contrast", + "blur": "Vervaging", + "noise": "Ruis", + "yellowish": "Geelheid", + "resolution": "Resolutie", + "processButton": "Scannereffect toepassen" + }, + "adjustColors": { + "name": "Kleuren Aanpassen", + "subtitle": "Helderheid, contrast, verzadiging en meer aanpassen in een PDF.", + "colorSettings": "Kleurinstellingen", + "brightness": "Helderheid", + "contrast": "Contrast", + "saturation": "Verzadiging", + "hueShift": "Tintrotatie", + "temperature": "Temperatuur", + "tint": "Tint", + "gamma": "Gamma", + "sepia": "Sepia", + "processButton": "Kleuraanpassingen toepassen" + }, + "backgroundColor": { + "name": "Achtergrondkleur", + "subtitle": "De achtergrondkleur van een PDF wijzigen." + }, + "changeTextColor": { + "name": "Tekstkleur", + "subtitle": "De tekstkleur van een PDF wijzigen." + }, + "addStamps": { + "name": "Stempels Toevoegen", + "subtitle": "Afbeeldingsstempels toevoegen aan een PDF met de werkbalk Annotatie.", + "usernameLabel": "Gebruikersnaam stempelen", + "usernamePlaceholder": "Voer je naam in (voor stempels)", + "usernameHint": "Deze naam verschijnt op stempels die je aanmaakt." + }, + "removeAnnotations": { + "name": "Annotaties Verwijderen", + "subtitle": "Commentaar, markeringen en links verwijderen." + }, + "pdfFormFiller": { + "name": "PDF-formulier Invullen", + "subtitle": "Formulieren invullen vanuit de browser, incl. ondersteuning voor XFA-formulieren." + }, + "createPdfForm": { + "name": "PDF-formulier Aanmaken", + "subtitle": "Invulbare PDF-formulieren aanmaken met drag-and-drop tekstvelden." + }, + "removeBlankPages": { + "name": "Blanco pagina's Verwijderen", + "subtitle": "Blanco pagina's automatisch detecteren en verwijderen.", + "sensitivityHint": "Hoger = strenger, alleen volledig lege pagina's. Lager = staat pagina's met enige inhoud toe." + }, + "imageToPdf": { + "name": "Afbeelding naar PDF", + "subtitle": "Converteer JPG, PNG, BMP, GIF, TIFF, PNM, PGM, PBM, PPM, PAM, JXR, JPX, JP2, PSD, SVG, HEIC, WebP naar PDF." + }, + "pngToPdf": { + "name": "PNG naar PDF", + "subtitle": "Maak een PDF van een of meer PNG-afbeeldingen." + }, + "webpToPdf": { + "name": "WebP naar PDF", + "subtitle": "Maak een PDF van een of meer WebP-afbeeldingen." + }, + "svgToPdf": { + "name": "SVG naar PDF", + "subtitle": "Maak een PDF van een of meer SVG-afbeeldingen." + }, + "bmpToPdf": { + "name": "BMP naar PDF", + "subtitle": "Maak een PDF van een of meer BMP-afbeeldingen." + }, + "heicToPdf": { + "name": "HEIC naar PDF", + "subtitle": "Maak een PDF van een of meer HEIC-afbeeldingen." + }, + "tiffToPdf": { + "name": "TIFF naar PDF", + "subtitle": "Maak een PDF van een of meer TIFF-afbeeldingen." + }, + "textToPdf": { + "name": "Tekst naar PDF", + "subtitle": "Converteer een bestand met platte tekst naar een PDF." + }, + "jsonToPdf": { + "name": "JSON naar PDF", + "subtitle": "Converteer JSON-bestanden naar PDF-formaat." + }, + "pdfToJpg": { + "name": "PDF naar JPG", + "subtitle": "Converteer elke PDF-pagina naar een JPG-afbeelding." + }, + "pdfToPng": { + "name": "PDF naar PNG", + "subtitle": "Converteer elke PDF-pagina naar een PNG-afbeelding." + }, + "pdfToWebp": { + "name": "PDF naar WebP", + "subtitle": "Converteer elke PDF-pagina naar een WebP-afbeelding." + }, + "pdfToBmp": { + "name": "PDF naar BMP", + "subtitle": "Converteer elke PDF-pagina naar een BMP-afbeelding." + }, + "pdfToTiff": { + "name": "PDF naar TIFF", + "subtitle": "Converteer elke PDF-pagina naar een TIFF-afbeelding." + }, + "pdfToGreyscale": { + "name": "PDF naar Grijswaarden", + "subtitle": "Converteer alle kleuren naar zwart-wit." + }, + "pdfToJson": { + "name": "PDF naar JSON", + "subtitle": "Converteer PDF-bestanden naar JSON-formaat." + }, + "ocrPdf": { + "name": "OCR PDF", + "subtitle": "Maak een PDF doorzoekbaar en kopieerbaar." + }, + "alternateMix": { + "name": "Pagina's Afwisselen & Mixen", + "subtitle": "PDF's samenvoegen met afwisselende pagina's vanuit elke PDF. Bladwijzers behouden." + }, + "addAttachments": { + "name": "Bijlagen Toevoegen", + "subtitle": "Een of meer bestanden toevoegen aan een PDF." + }, + "extractAttachments": { + "name": "Bijlagen Extraheren", + "subtitle": "Extraheer alle ingevoegde bestanden als ZIP uit PDF('s)." + }, + "editAttachments": { + "name": "Bijlagen Bewerken", + "subtitle": "Bijlagen in een PDF weergeven of verwijderen." + }, + "dividePages": { + "name": "Paginas Opdelen", + "subtitle": "Pagina's horizontaal of verticaal opdelen." + }, + "addBlankPage": { + "name": "Blanco pagina Toevoegen", + "subtitle": "Een blanco pagina ergens in een PDF invoegen." + }, + "reversePages": { + "name": "Paginavolgorde Omkeren", + "subtitle": "De volgorde omkeren van alle pagina's in een document." + }, + "rotatePdf": { + "name": "PDF Roteren", + "subtitle": "Pagina's roteren in stappen van 90 graden." + }, + "rotateCustom": { + "name": "Roteren met aangepaste hoek", + "subtitle": "Pagina's roteren met elke gewenste hoek." + }, + "nUpPdf": { + "name": "N+ PDF", + "subtitle": "Meerdere pagina's op één vel arrangeren." + }, + "combineToSinglePage": { + "name": "Combineren tot Enkele pagina", + "subtitle": "Alle pagina's aan elkaar plakken tot een doorlopende rol." + }, + "viewMetadata": { + "name": "Metadata Weergeven", + "subtitle": "De verborgen eigenschappen van een PDF weergeven." + }, + "editMetadata": { + "name": "Metadata Bewerken", + "subtitle": "Auteur, titel en andere eigenschappen aanpassen." + }, + "pdfsToZip": { + "name": "PDF naar ZIP", + "subtitle": "Meerdere PDF's archiveren in een ZIP-bestand." + }, + "comparePdfs": { + "name": "PDF's Vergelijken", + "subtitle": "Twee PDF's zij-aan-zij vergelijken.", + "firstPdf": "Eerste PDF", + "secondPdf": "Twede PDF", + "clickOrDrop": "Klikken of neerslepen", + "page": "Pagina", + "overlay": "Overlay", + "sideBySide": "Zij-aan-zij", + "flicker": "Wisselen", + "syncScroll": "Synchroon bladeren", + "export": "Exporteren", + "exportAsPdf": "Exporteren als PDF", + "splitView": "Gesplitste weergave", + "alternating": "Afwisselend", + "leftDocument": "Linker document", + "rightDocument": "Rechter document", + "original": "Origineel", + "modified": "Aangepast", + "searchChanges": "Verschillen zoeken...", + "deleted": "Verwijderd", + "added": "Toegevoegd", + "prevPage": "Vorige pagina", + "nextPage": "Volgende pagina", + "prevChange": "Vorige wijziging", + "nextChange": "Volgende wijziging", + "uploadTwoPdfs": "Laad twee PDF's om verschillen te zien.", + "noDifferences": "Geen verschillen gevonden op deze pagina.", + "noMatchingChanges": "Geen verschillen komen overeen met het filter.", + "pageNotExist": "Pagina {{page}} bestaat niet in deze PDF.", + "noPairedPage": "Geen overeenkomstige pagina voor deze zijde.", + "buildingModel": "Model van overeenkomsten bouwen...", + "indexingPdf": "PDF indexeren {{num}} pagina {{page}} van {{total}}...", + "loadingComparison": "Vergelijking laden {{current}} van {{total}}...", + "runningOcr": "OCR uitvoeren op pagina {{page}}...", + "preparingExport": "PDF voorbereiden voor exporteren...", + "renderingPage": "Pagina genereren {{current}} van {{total}}...", + "exportError": "Fout bij exporteren", + "exportFailed": "Kon geen PDF van vergelijking exporteren.", + "loadingFile": "Laden {{name}}...", + "invalidFile": "Ongeldig bestand", + "invalidFileMsg": "Selecteer een geldig PDF-bestand.", + "loadError": "Kan PDF niet laden. Mogelijk is het beschadigd of beveiligd." + }, + "posterizePdf": { + "name": "PDF-Poster", + "subtitle": "Een grote pagina opdelen in meerdere kleinere pagina's." + }, + "fixPageSize": { + "name": "Paginagrootte Fiksen", + "subtitle": "Alle pagina's aanpassen tot een uniform formaat." + }, + "linearizePdf": { + "name": "PDF Lineariseren", + "subtitle": "Een PDF optimaliseren voor snelle webweergave." + }, + "pageDimensions": { + "name": "Pagina Afmetingen", + "subtitle": "Paginagrootte, oriëntatie en eenheden analyseren." + }, + "removeRestrictions": { + "name": "Beperkingen Verwijderen", + "subtitle": "Beveiligingswachtwoord en -beperkingen verwijderen van digitaal ondertekende PDF-bestanden." + }, + "repairPdf": { + "name": "PDF Repareren", + "subtitle": "Gegevens herstellen van beschadigde PDF-bestanden." + }, + "encryptPdf": { + "name": "PDF Versleutelen", + "subtitle": "Een PDF vergrendelen door toevoeging van een toegangswachtwoord." + }, + "sanitizePdf": { + "name": "PDF Opschonen", + "subtitle": "Metadata, annotaties, scripts en meer verwijderen." + }, + "decryptPdf": { + "name": "PDF Ontgrendelen", + "subtitle": "Een PDF ontgrendelen door het verwijderen van de wachtwoordbeveiliging." + }, + "flattenPdf": { + "name": "PDF Platmaken", + "subtitle": "Formuliervelden en annotaties onbewerkbaar maken." + }, + "removeMetadata": { + "name": "Metadata Verwijderen", + "subtitle": "Verborgen gegevens verwijderen uit een PDF." + }, + "changePermissions": { + "name": "Rechten Aanpassen", + "subtitle": "Gebruikersrechten van een PDF instellen of aanpasssen." + }, + "odtToPdf": { + "name": "ODT naar PDF", + "subtitle": "Converteer OpenDocument-tekstbestanden naar PDF-formaat. Ondersteunt meerdere bestanden.", + "acceptedFormats": "ODT-bestanden", + "convertButton": "Omzetten naar PDF" + }, + "csvToPdf": { + "name": "CSV naar PDF", + "subtitle": "Converteer CSV-spreadsheetbestanden naar PDF-formaat. Ondersteunt meerdere bestanden.", + "acceptedFormats": "CSV-bestanden", + "convertButton": "Omzetten naar PDF" + }, + "rtfToPdf": { + "name": "RTF naar PDF", + "subtitle": "Converteer Rich Text Format-documenten naar PDF. Ondersteunt meerdere bestanden.", + "acceptedFormats": "RTF-bestanden", + "convertButton": "Omzetten naar PDF" + }, + "wordToPdf": { + "name": "Word naar PDF", + "subtitle": "Converteer Word-documenten (DOCX, DOC, ODT, RTF) naar PDF-formaat. Ondersteunt meerdere bestanden.", + "acceptedFormats": "DOCX-, DOC-, ODT-, RTF-bestanden", + "convertButton": "Omzetten naar PDF" + }, + "excelToPdf": { + "name": "Excel naar PDF", + "subtitle": "Converteer Excel-spreadsheets (XLSX, XLS, ODS, CSV) naar PDF-formaat. Ondersteunt meerdere bestanden.", + "acceptedFormats": "XLSX-, XLS-, ODS-, CSV-bestanden", + "convertButton": "Omzetten naar PDF" + }, + "powerpointToPdf": { + "name": "PowerPoint naar PDF", + "subtitle": "Converteer PowerPoint-presentaties (PPTX, PPT, ODP) naar PDF-formaat. Ondersteunt meerdere bestanden.", + "acceptedFormats": "PPTX-, PPT-, ODP-bestanden", + "convertButton": "Omzetten naar PDF" + }, + "markdownToPdf": { + "name": "Markdown naar PDF", + "subtitle": "Schrijf of plak Markdown en zet het om in een opgemaakte PDF.", + "paneMarkdown": "Markdown", + "panePreview": "Voorbeeld", + "btnUpload": "Laden", + "btnSyncScroll": "Synchroon bladeren", + "btnSettings": "Instellingen", + "btnExportPdf": "PDF exporteren", + "settingsTitle": "Markdown-instellingen", + "settingsPreset": "Voorinstelling", + "presetDefault": "Standaard (als GFM)", + "presetCommonmark": "CommonMark (strict)", + "presetZero": "Minimaal (geen functies)", + "settingsOptions": "Markdown-opties", + "optAllowHtml": "HTML-tags toestaan", + "optBreaks": "Newlines omzetten naar
", + "optLinkify": "URL's autom. omzetten naar links", + "optTypographer": "Typograaf (slimme aanhalingstekens, etc.)" + }, + "pdfBooklet": { + "name": "PDF Boekje", + "subtitle": "De pagina's opnieuw ordenen voor het afdrukken van een dubbelzijdig boekje. Vouw en niet ze om een boekje te maken.", + "howItWorks": "Het werkt zo:", + "step1": "Laad een PDF-bestand.", + "step2": "Pagina's worden in brochurevolgorde gerangschikt.", + "step3": "Dubbelzijdig afdrukken, omdraaien langs de korte kant, vouwen en nieten.", + "paperSize": "Paperformaat", + "orientation": "Oriëntatie", + "portrait": "Staand", + "landscape": "Liggend", + "pagesPerSheet": "Pagina's per vel", + "createBooklet": "Boekje aanmaken", + "processing": "Verwerken...", + "pageCount": "Het aantal pagina's wordt indien nodig op een meervoud van 4 afgerond." + }, + "xpsToPdf": { + "name": "XPS naar PDF", + "subtitle": "Converteer XPS/OXPS-documenten naar PDF-formaat. Ondersteunt meerdere bestanden.", + "acceptedFormats": "XPS-, OXPS-bestanden", + "convertButton": "Omzetten naar PDF" + }, + "mobiToPdf": { + "name": "MOBI naar PDF", + "subtitle": "Converteer MOBI e-books naar PDF-formaat. Ondersteunt meerdere bestanden.", + "acceptedFormats": "MOBI-bestanden", + "convertButton": "Omzetten naar PDF" + }, + "epubToPdf": { + "name": "EPUB naar PDF", + "subtitle": "Converteer EPUB-e-books naar PDF-formaat. Ondersteunt meerdere bestanden.", + "acceptedFormats": "EPUB-bestanden", + "convertButton": "Omzetten naar PDF" + }, + "fb2ToPdf": { + "name": "FB2 naar PDF", + "subtitle": "Converteer FictionBook (FB2) e-boeken naar PDF-formaat. Ondersteunt meerdere bestanden.", + "acceptedFormats": "FB2-bestanden", + "convertButton": "Omzetten naar PDF" + }, + "cbzToPdf": { + "name": "CBZ naar PDF", + "subtitle": "Converteer stripboekenarchieven (CBZ/CBR) naar PDF-formaat. Ondersteunt meerdere bestanden.", + "acceptedFormats": "CBZ-, CBR-bestanden", + "convertButton": "Omzetten naar PDF" + }, + "wpdToPdf": { + "name": "WPD naar PDF", + "subtitle": "Converteer WordPerfect-documenten (WPD) naar PDF-formaat. Ondersteunt meerdere bestanden.", + "acceptedFormats": "WPD-bestanden", + "convertButton": "Omzetten naar PDF" + }, + "wpsToPdf": { + "name": "WPS naar PDF", + "subtitle": "Converteer WPS Office-documenten naar PDF-formaat. Ondersteunt meerdere bestanden.", + "acceptedFormats": "WPS-bestanden", + "convertButton": "Omzetten naar PDF" + }, + "xmlToPdf": { + "name": "XML naar PDF", + "subtitle": "Converteer XML-documenten naar PDF-formaat. Ondersteunt meerdere bestanden.", + "acceptedFormats": "XML-bestanden", + "convertButton": "Omzetten naar PDF" + }, + "pagesToPdf": { + "name": "Pages naar PDF", + "subtitle": "Converteer Apple Pages-documenten naar PDF-formaat. Ondersteunt meerdere bestanden.", + "acceptedFormats": "Pages-bestanden", + "convertButton": "Omzetten naar PDF" + }, + "odgToPdf": { + "name": "ODG naar PDF", + "subtitle": "Converteer OpenDocument Graphics (ODG)-bestanden naar PDF-formaat. Ondersteunt meerdere bestanden.", + "acceptedFormats": "ODG-bestanden", + "convertButton": "Omzetten naar PDF" + }, + "odsToPdf": { + "name": "ODS naar PDF", + "subtitle": "Converteer OpenDocument Spreadsheet (ODS)-bestanden naar PDF-formaat. Ondersteunt meerdere bestanden.", + "acceptedFormats": "ODS-bestanden", + "convertButton": "Omzetten naar PDF" + }, + "odpToPdf": { + "name": "ODP naar PDF", + "subtitle": "Converteer OpenDocument-presentatie (ODP) bestanden naar PDF-formaat. Ondersteunt meerdere bestanden.", + "acceptedFormats": "ODP-bestanden", + "convertButton": "Omzetten naar PDF" + }, + "pubToPdf": { + "name": "PUB naar PDF", + "subtitle": "Converteer Microsoft Publisher (PUB) bestanden naar PDF-formaat. Ondersteunt meerdere bestanden.", + "acceptedFormats": "PUB-bestanden", + "convertButton": "Omzetten naar PDF" + }, + "vsdToPdf": { + "name": "VSD naar PDF", + "subtitle": "Converteer Microsoft Visio (VSD, VSDX) bestanden naar PDF-formaat. Ondersteunt meerdere bestanden.", + "acceptedFormats": "VSD-, VSDX-bestanden", + "convertButton": "Omzetten naar PDF" + }, + "psdToPdf": { + "name": "PSD naar PDF", + "subtitle": "Converteer Adobe Photoshop (PSD) bestanden naar PDF-formaat. Ondersteunt meerdere bestanden.", + "acceptedFormats": "PSD-bestanden", + "convertButton": "Omzetten naar PDF" + }, + "pdfToSvg": { + "name": "PDF naar SVG", + "subtitle": "Converteer elke pagina van een PDF-bestand naar een schaalbare vectorafbeelding (SVG) voor perfecte kwaliteit op elke grootte." + }, + "extractTables": { + "name": "PDF-tabellen Extraheren", + "subtitle": "Tabellen uit PDF-bestanden halen en exporteren als CSV, JSON of Markdown." + }, + "pdfToCsv": { + "name": "PDF naar CSV", + "subtitle": "Tabellen uit een PDF halen en converteren naar CSV-formaat." + }, + "pdfToExcel": { + "name": "PDF naar Excel", + "subtitle": "Tabellen uit een PDF halen en converten naar Excel (XLSX) formaat." + }, + "pdfToText": { + "name": "PDF naar Text", + "subtitle": "Tekst uit een PDF-bestanden halen en opslaan als gewone tekst (.txt). Ondersteunt meerdere bestanden.", + "note": "Dit hulpmiddel werkt ALLEEN met digitaal gemaakte PDF's. Gebruik voor gescande documenten of op afbeeldingen gebaseerde PDF's in plaats hiervan de OCR PDF-tool.", + "convertButton": "Tekst extraheren" + }, + "digitalSignPdf": { + "name": "Digitale handtekening PDF", + "pageTitle": "Digitale handtekening PDF - Cryptografische handtekening toevoegen | BentoPDF", + "subtitle": "Een cryptografische digitale handtekening toevoegen aan een PDF met behulp van X.509-certificaten. Ondersteunt PKCS#12 (.pfx, .p12) en PEM-formaten. Jouw privésleutel verlaat jouw browser nooit.", + "certificateSection": "Certificaat", + "uploadCert": "Certificaat laden (.pfx, .p12)", + "certPassword": "Certificaatwachtwoord", + "certPasswordPlaceholder": "Voer het wachtwoord van het certificaat in", + "certInfo": "Certificaatinformatie", + "certSubject": "Onderwerp", + "certIssuer": "Uitgever", + "certValidity": "Geldigheid", + "signatureDetails": "Signature Details (Optioneel)", + "reason": "Reden", + "reasonPlaceholder": "bijv., Ik keur dit document goed", + "location": "Plaats", + "locationPlaceholder": "bijv., New York, USA", + "contactInfo": "Contactinformatie", + "contactPlaceholder": "bijv., email@example.com", + "applySignature": "Digitale handtekening toepassen", + "successMessage": "PDF is ondertekend! De handtekening kan in elke PDF-lezer worden geverifieerd." + }, + "validateSignaturePdf": { + "name": "PDF-handtekening valideren", + "pageTitle": "PDF-handtekening valideren - Digitale handtekeningen verifiëren | BentoPDF", + "subtitle": "Digitale handtekeningen in een PDF-bestanden verifiëren. Controleer de geldigheid van het certificaat, bekijk de gegevens van de ondertekenaar en bevestig de integriteit van het document. Alle verwerking gebeurt binnen jouw browser." + }, + "emailToPdf": { + "name": "E-mail naar PDF", + "subtitle": "Converteer e-mailbestanden (EML, MSG) naar PDF-formaat. Ondersteunt Outlook-exports en standaard e-mailformaten.", + "acceptedFormats": "EML-, MSG-bestanden", + "convertButton": "Omzetten naar PDF" + }, + "fontToOutline": { + "name": "Lettertype naar Contour", + "subtitle": "Converteer alle lettertypen naar vector-contouren voor consistente weergave over alle apparaten." + }, + "deskewPdf": { + "name": "PDF Rechttrekken", + "subtitle": "Scheef gescande pagina's automatisch rechttrekken met OpenCV." + }, + "pdfToWord": { + "name": "PDF naar Word", + "subtitle": "Converteer PDF-bestanden naar bewerkbare Word-documenten." + }, + "extractImages": { + "name": "Afbeeldingen extraheren", + "subtitle": "Alle ingesloten afbeeldingen uit PDF-bestanden halen." + }, + "pdfToMarkdown": { + "name": "PDF naar Markdown", + "subtitle": "Converteer PDF-tekst en tabellen naar Markdown-formaat." + }, + "preparePdfForAi": { + "name": "PDF voorbereiden voor AI", + "subtitle": "PDF-inhoud extraheren als LlamaIndex JSON voor RAG/LLM-pipelines." + }, + "pdfOcg": { + "name": "PDF-lagen (OCG)", + "subtitle": "OCG-lagen in een PDF bekijken, wisselen, toevoegen en verwijderen." + }, + "pdfToPdfa": { + "name": "PDF naar PDF/A", + "subtitle": "Converteer PDF naar PDF/A voor langetermijnarchivering." + }, + "rasterizePdf": { + "name": "PDF Rasteren", + "subtitle": "Converteer PDF naar beeldgebaseerde PDF. Lagen afvlakken en selecteerbare tekst verwijderen." + }, + "pdfWorkflow": { + "name": "PDF Werkproces Samenstellen", + "subtitle": "Bouw specifieke PDF-verwerkingsprocessen met een visuele knooppunten-bewerking.", + "nodes": "Knooppunten", + "searchNodes": "Knooppunten zoeken...", + "run": "Uitvoeren", + "clear": "Wissen", + "save": "Opslaan", + "load": "Laden", + "export": "Exporteren", + "import": "Importeren", + "ready": "Gereed", + "settings": "Instellingen", + "processing": "Verwerken...", + "saveTemplate": "Sjabloon opslaan", + "templateName": "Sjabloonnaam", + "templatePlaceholder": "bijv. Factuur-werkproces", + "cancel": "Annuleren", + "loadTemplate": "Sjabloon laden", + "noTemplates": "Nog geen opgeslagen sjablonen.", + "ok": "OK", + "workflowCompleted": "Werkproces voltooid", + "errorDuringExecution": "Fout tijdens uitvoering", + "addNodeError": "Voeg minimaal één knooppunt toe om het werkproces uit te voeren.", + "needInputOutput": "Het werkproces heeft tenminste een invoerknoopunt en een uitvoer-knooppunt nodig.", + "enterName": "Voer een naam in.", + "templateExists": "Er bestaat al een sjabloon met deze naam.", + "templateSaved": "Sjabloon \"{{name}}\" opgeslagen.", + "templateLoaded": "Sjabloon \"{{name}}\" geladen.", + "failedLoadTemplate": "Sjabloon laden mislukt.", + "noSettings": "Geen configureerbare instellingen voor deze node.", + "advancedSettings": "Geavanceerde instellingen" + } +} diff --git a/public/locales/pt/common.json b/public/locales/pt/common.json index e446088..632ebb9 100644 --- a/public/locales/pt/common.json +++ b/public/locales/pt/common.json @@ -66,7 +66,34 @@ "pdfOrImages": "PDFs ou Imagens", "filesNeverLeave": "Seus arquivos nunca saem do seu dispositivo.", "addMore": "Adicionar Mais Arquivos", - "clearAll": "Limpar Tudo" + "clearAll": "Limpar Tudo", + "clearFiles": "Limpar arquivos", + "hints": { + "singlePdf": "Um único arquivo PDF", + "pdfFile": "Arquivo PDF", + "multiplePdfs2": "Múltiplos arquivos PDF (pelo menos 2)", + "bmpImages": "Imagens BMP", + "oneOrMorePdfs": "Um ou mais arquivos PDF", + "pdfDocuments": "Documentos PDF", + "oneOrMoreCsv": "Um ou mais arquivos CSV", + "multiplePdfsSupported": "Múltiplos arquivos PDF suportados", + "singleOrMultiplePdfs": "Um ou vários arquivos PDF suportados", + "singlePdfFile": "Um único arquivo PDF", + "pdfWithForms": "Arquivo PDF com campos de formulário", + "heicImages": "Imagens HEIC/HEIF", + "jpgImages": "Imagens JPG, JPEG, JP2, JPX", + "pdfsOrImages": "PDFs ou imagens", + "oneOrMoreOdt": "Um ou mais arquivos ODT", + "singlePdfOnly": "Apenas um arquivo PDF", + "pdfFiles": "Arquivos PDF", + "multiplePdfs": "Múltiplos arquivos PDF", + "pngImages": "Imagens PNG", + "pdfFilesOneOrMore": "Arquivos PDF (um ou mais)", + "oneOrMoreRtf": "Um ou mais arquivos RTF", + "svgGraphics": "Gráficos SVG", + "tiffImages": "Imagens TIFF", + "webpImages": "Imagens WebP" + } }, "loader": { "processing": "Processando..." @@ -170,7 +197,8 @@ "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." - } + }, + "sectionTitle": "Perguntas frequentes" }, "testimonials": { "title": "O que nossos", @@ -226,7 +254,8 @@ "error": "Erro", "success": "Sucesso", "file": "Arquivo", - "files": "Arquivos" + "files": "Arquivos", + "close": "Fechar" }, "about": { "hero": { @@ -319,5 +348,18 @@ "errorRendering": "Falha ao renderizar miniaturas das páginas", "error": "Erro", "failedToLoad": "Falha ao carregar" + }, + "howItWorks": { + "title": "Como funciona", + "step1": "Clique ou arraste seu arquivo aqui", + "step2": "Clique no botão de processamento", + "step3": "Salve seu arquivo processado instantaneamente" + }, + "relatedTools": { + "title": "Ferramentas PDF relacionadas" + }, + "simpleMode": { + "title": "Ferramentas PDF", + "subtitle": "Selecione uma ferramenta para começar" } } diff --git a/public/locales/pt/tools.json b/public/locales/pt/tools.json index eb42770..24eaafe 100644 --- a/public/locales/pt/tools.json +++ b/public/locales/pt/tools.json @@ -22,7 +22,29 @@ }, "compressPdf": { "name": "Comprimir PDF", - "subtitle": "Reduza o tamanho do arquivo do seu PDF." + "subtitle": "Reduza o tamanho do arquivo do seu PDF.", + "algorithmLabel": "Algoritmo de Compressão", + "condense": "Condense (Recomendado)", + "photon": "Photon (Para PDFs com Muitas Fotos)", + "condenseInfo": "Condense usa compressão avançada: remove dados desnecessários, otimiza imagens, subconjunta fontes. Ideal para a maioria dos PDFs.", + "photonInfo": "Photon converte páginas em imagens. Use para PDFs com muitas fotos/digitalizados.", + "photonWarning": "Aviso: O texto ficará não selecionável e os links deixarão de funcionar.", + "levelLabel": "Nível de Compressão", + "light": "Leve (Preservar Qualidade)", + "balanced": "Equilibrado (Recomendado)", + "aggressive": "Agressivo (Arquivos Menores)", + "extreme": "Extremo (Compressão Máxima)", + "grayscale": "Converter para Escala de Cinza", + "grayscaleHint": "Reduz o tamanho do arquivo removendo informações de cor", + "customSettings": "Configurações Personalizadas", + "customSettingsHint": "Ajuste fino dos parâmetros de compressão:", + "outputQuality": "Qualidade de Saída", + "resizeImagesTo": "Redimensionar Imagens Para", + "onlyProcessAbove": "Processar Apenas Acima de", + "removeMetadata": "Remover metadados", + "subsetFonts": "Subconjunto de fontes (remover glifos não utilizados)", + "removeThumbnails": "Remover miniaturas incorporadas", + "compressButton": "Comprimir PDF" }, "pdfEditor": { "name": "Editor de PDF", @@ -64,9 +86,14 @@ "name": "Números de Página", "subtitle": "Insira números de página no seu documento." }, + "batesNumbering": { + "name": "Numeração Bates", + "subtitle": "Adicionar números Bates sequenciais em um ou mais arquivos PDF." + }, "addWatermark": { "name": "Adicionar Marca d'Água", - "subtitle": "Carimbe texto ou uma imagem sobre as páginas do seu PDF." + "subtitle": "Carimbe texto ou uma imagem sobre as páginas do seu PDF.", + "applyToAllPages": "Aplicar a todas as páginas" }, "headerFooter": { "name": "Cabeçalho e Rodapé", @@ -76,6 +103,37 @@ "name": "Inverter Cores", "subtitle": "Crie uma versão em \"modo escuro\" do seu PDF." }, + "scannerEffect": { + "name": "Efeito Scanner", + "subtitle": "Faça seu PDF parecer um documento digitalizado.", + "scanSettings": "Configurações de Digitalização", + "colorspace": "Espaço de Cor", + "gray": "Cinza", + "border": "Borda", + "rotate": "Rotação", + "rotateVariance": "Variação de Rotação", + "brightness": "Brilho", + "contrast": "Contraste", + "blur": "Desfoque", + "noise": "Ruído", + "yellowish": "Amarelado", + "resolution": "Resolução", + "processButton": "Aplicar Efeito Scanner" + }, + "adjustColors": { + "name": "Ajustar Cores", + "subtitle": "Ajuste brilho, contraste, saturação e mais no seu PDF.", + "colorSettings": "Configurações de Cor", + "brightness": "Brilho", + "contrast": "Contraste", + "saturation": "Saturação", + "hueShift": "Matiz", + "temperature": "Temperatura", + "tint": "Tonalidade", + "gamma": "Gamma", + "sepia": "Sépia", + "processButton": "Aplicar Ajustes de Cor" + }, "backgroundColor": { "name": "Cor de Fundo", "subtitle": "Altere a cor de fundo do seu PDF." @@ -105,7 +163,8 @@ }, "removeBlankPages": { "name": "Remover Páginas em Branco", - "subtitle": "Detecte e exclua automaticamente páginas em branco." + "subtitle": "Detecte e exclua automaticamente páginas em branco.", + "sensitivityHint": "Maior = mais rigoroso, apenas páginas totalmente em branco. Menor = permite páginas com algum conteúdo." }, "imageToPdf": { "name": "Imagem para PDF", @@ -172,7 +231,7 @@ "subtitle": "Converta arquivos PDF para o formato JSON." }, "ocrPdf": { - "name": "OCR PDF", + "name": "OCR para PDF", "subtitle": "Torne um PDF pesquisável e copiável (reconhecimento de texto)." }, "alternateMix": { @@ -229,7 +288,47 @@ }, "comparePdfs": { "name": "Comparar PDFs", - "subtitle": "Compare dois PDFs lado a lado." + "subtitle": "Compare dois PDFs lado a lado.", + "firstPdf": "Primeiro PDF", + "secondPdf": "Segundo PDF", + "clickOrDrop": "Clique ou solte", + "page": "Página", + "overlay": "Sobreposição", + "sideBySide": "Lado a lado", + "flicker": "Alternância rápida", + "syncScroll": "Sincronizar rolagem", + "export": "Exportar", + "exportAsPdf": "Exportar como PDF", + "splitView": "Visualização dividida", + "alternating": "Alternado", + "leftDocument": "Documento esquerdo", + "rightDocument": "Documento direito", + "original": "Original", + "modified": "Modificado", + "searchChanges": "Pesquisar alterações...", + "deleted": "Excluído", + "added": "Adicionado", + "prevPage": "Página anterior", + "nextPage": "Próxima página", + "prevChange": "Alteração anterior", + "nextChange": "Próxima alteração", + "uploadTwoPdfs": "Envie dois PDFs para ver as diferenças.", + "noDifferences": "Nenhuma diferença detectada nesta página.", + "noMatchingChanges": "Nenhuma alteração corresponde ao filtro atual.", + "pageNotExist": "A página {{page}} não existe neste PDF.", + "noPairedPage": "Não há página pareada para este lado.", + "buildingModel": "Criando modelo de pareamento de páginas...", + "indexingPdf": "Indexando PDF {{num}}, página {{page}} de {{total}}...", + "loadingComparison": "Carregando comparação {{current}} de {{total}}...", + "runningOcr": "Executando OCR na página {{page}}...", + "preparingExport": "Preparando exportação em PDF...", + "renderingPage": "Renderizando página {{current}} de {{total}}...", + "exportError": "Erro de exportação", + "exportFailed": "Não foi possível exportar o PDF de comparação.", + "loadingFile": "Carregando {{name}}...", + "invalidFile": "Arquivo inválido", + "invalidFileMsg": "Selecione um arquivo PDF válido.", + "loadError": "Não foi possível carregar o PDF. Ele pode estar corrompido ou protegido por senha." }, "posterizePdf": { "name": "Posterizar PDF", @@ -294,192 +393,192 @@ "subtitle": "Endireite automaticamente páginas digitalizadas inclinadas usando OpenCV." }, "rotateCustom": { - "name": "Rotate by Custom Degrees", - "subtitle": "Rotate pages by any custom angle." + "name": "Rotacionar em Graus Customizáveis", + "subtitle": "Rotaciona páginas em qualquer ângulo customizado." }, "odtToPdf": { - "name": "ODT to PDF", - "subtitle": "Convert OpenDocument Text files to PDF format. Supports multiple files.", - "acceptedFormats": "ODT files", - "convertButton": "Convert to PDF" + "name": "ODT para PDF", + "subtitle": "Converte arquivos em formato OpenDocument Text para PDF. Suporta multiplos arquivos.", + "acceptedFormats": "Arquivos ODT", + "convertButton": "Converter para PDF" }, "csvToPdf": { - "name": "CSV to PDF", - "subtitle": "Convert CSV spreadsheet files to PDF format. Supports multiple files.", - "acceptedFormats": "CSV files", - "convertButton": "Convert to PDF" + "name": "CSV para PDF", + "subtitle": "Converte arquivos de planilhas CSV para PDF. Suporta múltiplos arquivos.", + "acceptedFormats": "Arquivos CSV", + "convertButton": "Converter para PDF" }, "rtfToPdf": { - "name": "RTF to PDF", - "subtitle": "Convert Rich Text Format documents to PDF. Supports multiple files.", - "acceptedFormats": "RTF files", - "convertButton": "Convert to PDF" + "name": "RTF para PDF", + "subtitle": "Converte documentos Rich Text Format para PDF. Suporta múltiplos arquivos.", + "acceptedFormats": "Arquivos RTF", + "convertButton": "Converter para PDF" }, "wordToPdf": { - "name": "Word to PDF", - "subtitle": "Convert Word documents (DOCX, DOC, ODT, RTF) to PDF format. Supports multiple files.", - "acceptedFormats": "DOCX, DOC, ODT, RTF files", - "convertButton": "Convert to PDF" + "name": "Word para PDF", + "subtitle": "Converte documentos Word documents (DOCX, DOC, ODT, RTF) para PDF. Suporta múltiplos arquivos.", + "acceptedFormats": "Arquivos DOCX, DOC, ODT, RTF", + "convertButton": "Converter para PDF" }, "excelToPdf": { - "name": "Excel to PDF", - "subtitle": "Convert Excel spreadsheets (XLSX, XLS, ODS, CSV) to PDF format. Supports multiple files.", - "acceptedFormats": "XLSX, XLS, ODS, CSV files", - "convertButton": "Convert to PDF" + "name": "Excel para PDF", + "subtitle": "Converte planilhas Excel (XLSX, XLS, ODS, CSV) para PDF. Suporta múltiplos arquivos.", + "acceptedFormats": "Arquivos XLSX, XLS, ODS, CSV", + "convertButton": "Converter para PDF" }, "powerpointToPdf": { - "name": "PowerPoint to PDF", - "subtitle": "Convert PowerPoint presentations (PPTX, PPT, ODP) to PDF format. Supports multiple files.", - "acceptedFormats": "PPTX, PPT, ODP files", - "convertButton": "Convert to PDF" + "name": "PowerPoint para PDF", + "subtitle": "Converte apresentações PowerPoint (PPTX, PPT, ODP) para PDF. Suporta múltiplos arquivos.", + "acceptedFormats": "Arquivos PPTX, PPT, ODP", + "convertButton": "Converter para PDF" }, "markdownToPdf": { - "name": "Markdown to PDF", - "subtitle": "Write or paste Markdown and export it as a beautifully formatted PDF.", + "name": "Markdown para PDF", + "subtitle": "Escreva ou cole Markdown e exporte como um belo formato PDF.", "paneMarkdown": "Markdown", - "panePreview": "Preview", + "panePreview": "Pré-visualizar", "btnUpload": "Upload", - "btnSyncScroll": "Sync Scroll", - "btnSettings": "Settings", - "btnExportPdf": "Export PDF", - "settingsTitle": "Markdown Settings", - "settingsPreset": "Preset", - "presetDefault": "Default (GFM-like)", - "presetCommonmark": "CommonMark (strict)", - "presetZero": "Minimal (no features)", - "settingsOptions": "Markdown Options", - "optAllowHtml": "Allow HTML tags", - "optBreaks": "Convert newlines to
", - "optLinkify": "Auto-convert URLs to links", - "optTypographer": "Typographer (smart quotes, etc.)" + "btnSyncScroll": "Sincronizar Scroll", + "btnSettings": "Configurações", + "btnExportPdf": "Exportar PDF", + "settingsTitle": "Configurações Markdown", + "settingsPreset": "Predefinição", + "presetDefault": "Padrão (tipo GFM)", + "presetCommonmark": "CommonMark (extrito)", + "presetZero": "Mínimo (sem features)", + "settingsOptions": "Opções de Markdown", + "optAllowHtml": "Permitir tags HTML", + "optBreaks": "Converter novas linhas em
", + "optLinkify": "Auto-converter URLs para links", + "optTypographer": "Tipógrafo (aspas inteligentes, etc.)" }, "pdfBooklet": { "name": "PDF Booklet", - "subtitle": "Rearrange pages for double-sided booklet printing. Fold and staple to create a booklet.", - "howItWorks": "How it works:", - "step1": "Upload a PDF file.", - "step2": "Pages will be rearranged in booklet order.", - "step3": "Print double-sided, flip on short edge, fold and staple.", - "paperSize": "Paper Size", - "orientation": "Orientation", - "portrait": "Portrait", - "landscape": "Landscape", - "pagesPerSheet": "Pages per Sheet", - "createBooklet": "Create Booklet", - "processing": "Processing...", - "pageCount": "Page count will be padded to multiple of 4 if needed." + "subtitle": "Reordena páginas para impressão de livreto frente e verso. Dobre e grampeie para criar um livreto.", + "howItWorks": "Como funciona:", + "step1": "Faça o upload de um arquivo PDF.", + "step2": "As páginas serão reordenadas na ordem de livretos.", + "step3": "Imprima frente e verso, vire pelo topo, dobre e grampeie.", + "paperSize": "Tamanho do Papel", + "orientation": "Orientação", + "portrait": "Retrato", + "landscape": "Paisagem", + "pagesPerSheet": "Páginas por Folha", + "createBooklet": "Criar Livreto", + "processing": "Processando...", + "pageCount": "A contagem de páginas será ajustada para um múltiplo de 4, caso necessário." }, "xpsToPdf": { - "name": "XPS to PDF", - "subtitle": "Convert XPS/OXPS documents to PDF format. Supports multiple files.", - "acceptedFormats": "XPS, OXPS files", - "convertButton": "Convert to PDF" + "name": "XPS para PDF", + "subtitle": "Converte XPS/OXPS para PDF. Suporta múltiplos arquivos.", + "acceptedFormats": "Arquivos XPS, OXPS", + "convertButton": "Converter para PDF" }, "mobiToPdf": { - "name": "MOBI to PDF", - "subtitle": "Convert MOBI e-books to PDF format. Supports multiple files.", - "acceptedFormats": "MOBI files", - "convertButton": "Convert to PDF" + "name": "MOBI para PDF", + "subtitle": "Converte e-books MOBI para PDF. Suporta múltiplos arquivos.", + "acceptedFormats": "Arquivos MOBI", + "convertButton": "Converter para PDF" }, "epubToPdf": { - "name": "EPUB to PDF", - "subtitle": "Convert EPUB e-books to PDF format. Supports multiple files.", - "acceptedFormats": "EPUB files", - "convertButton": "Convert to PDF" + "name": "EPUB para PDF", + "subtitle": "Converte e-books EPUB para PDF. Suporta múltiplos arquivos.", + "acceptedFormats": "Arquivos EPUB", + "convertButton": "Converter para PDF" }, "fb2ToPdf": { - "name": "FB2 to PDF", - "subtitle": "Convert FictionBook (FB2) e-books to PDF format. Supports multiple files.", - "acceptedFormats": "FB2 files", - "convertButton": "Convert to PDF" + "name": "FB2 para PDF", + "subtitle": "Converte e-books FictionBook (FB2) para PDF. Suporta múltiplos arquivos.", + "acceptedFormats": "Arquivos FB2", + "convertButton": "Converter para PDF" }, "cbzToPdf": { - "name": "CBZ to PDF", - "subtitle": "Convert comic book archives (CBZ/CBR) to PDF format. Supports multiple files.", - "acceptedFormats": "CBZ, CBR files", - "convertButton": "Convert to PDF" + "name": "CBZ para PDF", + "subtitle": "Converte comic book archives (CBZ/CBR) para PDF. Suporta múltiplos arquivos.", + "acceptedFormats": "Arquivos CBZ, CBR", + "convertButton": "Converter para PDF" }, "wpdToPdf": { - "name": "WPD to PDF", - "subtitle": "Convert WordPerfect documents (WPD) to PDF format. Supports multiple files.", - "acceptedFormats": "WPD files", - "convertButton": "Convert to PDF" + "name": "WPD para PDF", + "subtitle": "Converte WordPerfect (WPD) para PDF. Suporta múltiplos arquivos.", + "acceptedFormats": "Arquivos WPD", + "convertButton": "Converter para PDF" }, "wpsToPdf": { - "name": "WPS to PDF", - "subtitle": "Convert WPS Office documents to PDF format. Supports multiple files.", - "acceptedFormats": "WPS files", - "convertButton": "Convert to PDF" + "name": "WPS para PDF", + "subtitle": "Converte WPS Office para PDF. Suporta múltiplos arquivos.", + "acceptedFormats": "Arquivos WPS", + "convertButton": "Converter para PDF" }, "xmlToPdf": { - "name": "XML to PDF", - "subtitle": "Convert XML documents to PDF format. Supports multiple files.", - "acceptedFormats": "XML files", - "convertButton": "Convert to PDF" + "name": "XML para PDF", + "subtitle": "Converte XML para PDF. Suporta múltiplos arquivos.", + "acceptedFormats": "Arquivos XML", + "convertButton": "Converter para PDF" }, "pagesToPdf": { - "name": "Pages to PDF", - "subtitle": "Convert Apple Pages documents to PDF format. Supports multiple files.", - "acceptedFormats": "Pages files", - "convertButton": "Convert to PDF" + "name": "Pages para PDF", + "subtitle": "Converte Apple Pages para PDF. Suporta múltiplos arquivos.", + "acceptedFormats": "Arquivos Pages", + "convertButton": "Converter para PDF" }, "odgToPdf": { - "name": "ODG to PDF", - "subtitle": "Convert OpenDocument Graphics (ODG) files to PDF format. Supports multiple files.", - "acceptedFormats": "ODG files", - "convertButton": "Convert to PDF" + "name": "ODG para PDF", + "subtitle": "Converte OpenDocument Graphics (ODG) para PDF. Suporta múltiplos arquivos.", + "acceptedFormats": "Arquivos ODG", + "convertButton": "Converter para PDF" }, "odsToPdf": { - "name": "ODS to PDF", - "subtitle": "Convert OpenDocument Spreadsheet (ODS) files to PDF format. Supports multiple files.", - "acceptedFormats": "ODS files", - "convertButton": "Convert to PDF" + "name": "ODS para PDF", + "subtitle": "Converte OpenDocument Spreadsheet (ODS) para PDF. Suporta múltiplos arquivos.", + "acceptedFormats": "Arquivos ODS", + "convertButton": "Converter para PDF" }, "odpToPdf": { - "name": "ODP to PDF", - "subtitle": "Convert OpenDocument Presentation (ODP) files to PDF format. Supports multiple files.", - "acceptedFormats": "ODP files", - "convertButton": "Convert to PDF" + "name": "ODP para PDF", + "subtitle": "Converte OpenDocument Presentation (ODP) para PDF. Suporta múltiplos arquivos.", + "acceptedFormats": "Arquivos ODP", + "convertButton": "Converter para PDF" }, "pubToPdf": { - "name": "PUB to PDF", - "subtitle": "Convert Microsoft Publisher (PUB) files to PDF format. Supports multiple files.", - "acceptedFormats": "PUB files", - "convertButton": "Convert to PDF" + "name": "PUB para PDF", + "subtitle": "Converte Microsoft Publisher (PUB) para PDF. Suporta múltiplos arquivos.", + "acceptedFormats": "Arquivos PUB", + "convertButton": "Converter para PDF" }, "vsdToPdf": { - "name": "VSD to PDF", - "subtitle": "Convert Microsoft Visio (VSD, VSDX) files to PDF format. Supports multiple files.", - "acceptedFormats": "VSD, VSDX files", - "convertButton": "Convert to PDF" + "name": "VSD para PDF", + "subtitle": "Converte Microsoft Visio (VSD, VSDX) para PDF. Suporta múltiplos arquivos.", + "acceptedFormats": "Arquivos VSD, VSDX", + "convertButton": "Converter para PDF" }, "psdToPdf": { - "name": "PSD to PDF", - "subtitle": "Convert Adobe Photoshop (PSD) files to PDF format. Supports multiple files.", - "acceptedFormats": "PSD files", - "convertButton": "Convert to PDF" + "name": "PSD para PDF", + "subtitle": "Converte Adobe Photoshop (PSD) para PDF. Suporta múltiplos arquivos.", + "acceptedFormats": "Arquivos PSD", + "convertButton": "Converter para PDF" }, "pdfToSvg": { - "name": "PDF to SVG", - "subtitle": "Convert each page of a PDF file into a scalable vector graphic (SVG) for perfect quality at any size." + "name": "PDF para SVG", + "subtitle": "Converte cada página de um arquivo PDF em um scalable vector graphic (SVG) para qualidade perfeita em qualquer tamanho." }, "extractTables": { - "name": "Extract PDF Tables", - "subtitle": "Extract tables from PDF files and export as CSV, JSON, or Markdown." + "name": "Extrair Tabelas PDF", + "subtitle": "Extrai tabelas de arquivos PDF e exporta como CSV, JSON, ou Markdown." }, "pdfToCsv": { - "name": "PDF to CSV", - "subtitle": "Extract tables from PDF and convert to CSV format." + "name": "PDF para CSV", + "subtitle": "Extrai tabelas do PDF e converte em CSV." }, "pdfToExcel": { - "name": "PDF to Excel", - "subtitle": "Extract tables from PDF and convert to Excel (XLSX) format." + "name": "PDF para Excel", + "subtitle": "Extrai tabelas do PDF e converte em Excel (XLSX)." }, "pdfToText": { - "name": "PDF to Text", - "subtitle": "Extract text from PDF files and save as plain text (.txt). Supports multiple files.", - "note": "This tool works ONLY with digitally created PDFs. For scanned documents or image-based PDFs, use our OCR PDF tool instead.", - "convertButton": "Extract Text" + "name": "PDF para Texto", + "subtitle": "Extrai texto de arquivos PDF e salva como texto simples (.txt). Suporta múltiplos arquivos.", + "note": "Esta ferrament funciona SOMENTE com PDFs criados digitalmente. Para documentos escaneados ou PDFs baseados em imagens, use nossa ferramenta OCR para PDF no lugar.", + "convertButton": "Extrair Texto" }, "digitalSignPdf": { "name": "Assinatura Digital PDF", @@ -507,5 +606,66 @@ "name": "Validar Assinatura PDF", "pageTitle": "Validar Assinatura PDF - Verificar Assinaturas Digitais | BentoPDF", "subtitle": "Verifique assinaturas digitais em seus arquivos PDF. Verifique a validade do certificado e a integridade do documento." + }, + "pdfToWord": { + "name": "PDF para Word", + "subtitle": "Converter arquivos PDF em documentos Word editáveis." + }, + "extractImages": { + "name": "Extrair Imagens", + "subtitle": "Extrair todas as imagens incorporadas dos seus arquivos PDF." + }, + "pdfToMarkdown": { + "name": "PDF para Markdown", + "subtitle": "Converter texto e tabelas de PDF para formato Markdown." + }, + "preparePdfForAi": { + "name": "Preparar PDF para IA", + "subtitle": "Extrair conteúdo PDF como JSON LlamaIndex para pipelines RAG/LLM." + }, + "pdfOcg": { + "name": "Camadas PDF (OCG)", + "subtitle": "Visualizar, alternar, adicionar e excluir camadas OCG no seu PDF." + }, + "pdfToPdfa": { + "name": "PDF para PDF/A", + "subtitle": "Converter PDF em PDF/A para arquivamento de longo prazo." + }, + "rasterizePdf": { + "name": "Rasterizar PDF", + "subtitle": "Converter PDF em PDF baseado em imagens. Achatar camadas e remover texto selecionável." + }, + "pdfWorkflow": { + "name": "Construtor de fluxo de trabalho PDF", + "subtitle": "Crie pipelines de processamento PDF personalizados com um editor visual de nós.", + "nodes": "Nós", + "searchNodes": "Pesquisar nós...", + "run": "Executar", + "clear": "Limpar", + "save": "Salvar", + "load": "Carregar", + "export": "Exportar", + "import": "Importar", + "ready": "Pronto", + "settings": "Configurações", + "processing": "Processando...", + "saveTemplate": "Salvar modelo", + "templateName": "Nome do modelo", + "templatePlaceholder": "ex. Fluxo de trabalho de faturamento", + "cancel": "Cancelar", + "loadTemplate": "Carregar modelo", + "noTemplates": "Nenhum modelo salvo ainda.", + "ok": "OK", + "workflowCompleted": "Fluxo de trabalho concluído", + "errorDuringExecution": "Erro durante a execução", + "addNodeError": "Adicione pelo menos um nó para executar o fluxo de trabalho.", + "needInputOutput": "Seu fluxo de trabalho precisa de pelo menos um nó de entrada e um nó de saída para ser executado.", + "enterName": "Por favor, insira um nome.", + "templateExists": "Já existe um modelo com este nome.", + "templateSaved": "Modelo \"{{name}}\" salvo.", + "templateLoaded": "Modelo \"{{name}}\" carregado.", + "failedLoadTemplate": "Falha ao carregar o modelo.", + "noSettings": "Nenhuma configuração disponível para este nó.", + "advancedSettings": "Configurações avançadas" } } diff --git a/public/locales/sv/common.json b/public/locales/sv/common.json new file mode 100644 index 0000000..a63647d --- /dev/null +++ b/public/locales/sv/common.json @@ -0,0 +1,365 @@ +{ + "nav": { + "home": "Hem", + "about": "Om", + "contact": "Kontakt", + "licensing": "Licensiering", + "allTools": "Alla verktyg", + "openMainMenu": "Öppna huvudmenyn", + "language": "Språk" + }, + "donation": { + "message": "Gillar du BentoPDF? Hjälp oss att hålla det gratis och öppen källkod!", + "button": "Donera" + }, + "hero": { + "title": " ", + "pdfToolkit": "PDF-verktygslådan", + "builtForPrivacy": "byggd för integritet", + "noSignups": "Inga konton", + "unlimitedUse": "Obegränsad användning", + "worksOffline": "Fungerar offline", + "startUsing": "Börja använda nu" + }, + "usedBy": { + "title": "Används av företag och personer som arbetar på" + }, + "features": { + "title": "Varför välja", + "bentoPdf": "BentoPDF?", + "noSignup": { + "title": "Ingen registrering", + "description": "Börja direkt, inga konton eller e-postadresser." + }, + "noUploads": { + "title": "Inga uppladdningar", + "description": "100% på klientsidan, dina filer lämnar aldrig din enhet." + }, + "foreverFree": { + "title": "Gratis för alltid", + "description": "Alla verktyg, inga provperioder, inga betalväggar." + }, + "noLimits": { + "title": "Inga begränsningar", + "description": "Använd så mycket du vill, inga dolda gränser." + }, + "batchProcessing": { + "title": "Batchbearbetning", + "description": "Hantera obegränsade PDF-filer på en gång." + }, + "lightningFast": { + "title": "Blixtrande snabb", + "description": "Bearbeta PDF-filer direkt, utan väntan eller förseningar." + } + }, + "tools": { + "title": "Kom igång med", + "toolsLabel": "verktyg", + "subtitle": "Klicka på ett verktyg för att öppna filväljaren", + "searchPlaceholder": "Sök efter ett verktyg (t.ex. 'dela', 'organisera'...)", + "backToTools": "Tillbaka till verktyg", + "firstLoadNotice": "Första laddningen tar ett ögonblick eftersom vi hämtar vår konverteringsmotor. Därefter blir alla laddningar omedelbara." + }, + "upload": { + "clickToSelect": "Klicka för att välja en fil", + "orDragAndDrop": "eller dra och släpp", + "pdfOrImages": "PDF-filer eller bilder", + "filesNeverLeave": "Dina filer lämnar aldrig din enhet.", + "addMore": "Lägg till fler filer", + "clearAll": "Rensa allt", + "clearFiles": "Rensa filer", + "hints": { + "singlePdf": "Ett enskilt PDF-dokument", + "pdfFile": "PDF-fil", + "multiplePdfs2": "Flera PDF-filer (minst 2)", + "bmpImages": "BMP-bilder", + "oneOrMorePdfs": "En eller flera PDF-filer", + "pdfDocuments": "PDF-dokument", + "oneOrMoreCsv": "En eller flera CSV-filer", + "multiplePdfsSupported": "Flera PDF-filer stöds", + "singleOrMultiplePdfs": "Enskild eller flera PDF-filer stöds", + "singlePdfFile": "Enskild PDF-fil", + "pdfWithForms": "PDF-fil med formulärfält", + "heicImages": "HEIC/HEIF-bilder", + "jpgImages": "JPG, JPEG, JP2, JPX-bilder", + "pdfsOrImages": "PDF-filer eller bilder", + "oneOrMoreOdt": "En eller flera ODT-filer", + "singlePdfOnly": "Endast enskild PDF-fil", + "pdfFiles": "PDF-filer", + "multiplePdfs": "Flera PDF-filer", + "pngImages": "PNG-bilder", + "pdfFilesOneOrMore": "PDF-filer (en eller flera)", + "oneOrMoreRtf": "En eller flera RTF-filer", + "svgGraphics": "SVG-grafik", + "tiffImages": "TIFF-bilder", + "webpImages": "WebP-bilder" + } + }, + "howItWorks": { + "title": "Så här fungerar det", + "step1": "Klicka eller dra och släpp din fil för att börja", + "step2": "Klicka på bearbeta-knappen för att starta", + "step3": "Spara din bearbetade fil direkt" + }, + "relatedTools": { + "title": "Relaterade PDF-verktyg" + }, + "loader": { + "processing": "Bearbetar..." + }, + "alert": { + "title": "Varning", + "ok": "OK" + }, + "preview": { + "title": "Förhandsvisning", + "downloadAsPdf": "Ladda ner som PDF", + "close": "Stäng" + }, + "settings": { + "title": "Inställningar", + "shortcuts": "Genvägar", + "preferences": "Inställningar", + "displayPreferences": "Visningsinställningar", + "searchShortcuts": "Sök genvägar...", + "shortcutsInfo": "Håll ned tangenterna för att ställa in en genväg. Ändringar sparas automatiskt.", + "shortcutsWarning": "⚠️ Undvik vanliga webbläsargenvägar (Cmd/Ctrl+W, Cmd/Ctrl+T, Cmd/Ctrl+N osv.) eftersom de kanske inte fungerar tillförlitligt.", + "import": "Importera", + "export": "Exportera", + "resetToDefaults": "Återställ till standard", + "fullWidthMode": "Fullbreddsläge", + "fullWidthDescription": "Använd hela skärmbredden för alla verktyg istället för en centrerad behållare", + "settingsAutoSaved": "Inställningar sparas automatiskt", + "clickToSet": "Klicka för att ställa in", + "pressKeys": "Tryck tangenter...", + "warnings": { + "alreadyInUse": "Genvägen används redan", + "assignedTo": "är redan tilldelad till:", + "chooseDifferent": "Välj en annan genväg.", + "reserved": "Reserverad genvägsvarning", + "commonlyUsed": "används vanligtvis för:", + "unreliable": "Den här genvägen fungerar kanske inte tillförlitligt eller kan komma i konflikt med webbläsarens/systemets beteende.", + "useAnyway": "Vill du använda den ändå?", + "resetTitle": "Återställ genvägar", + "resetMessage": "Är du säker på att du vill återställa alla genvägar till standard?

Den här åtgärden kan inte ångras.", + "importSuccessTitle": "Import lyckades", + "importSuccessMessage": "Genvägar importerades!", + "importFailTitle": "Import misslyckades", + "importFailMessage": "Det gick inte att importera genvägar. Ogiltigt filformat." + } + }, + "warning": { + "title": "Varning", + "cancel": "Avbryt", + "proceed": "Fortsätt" + }, + "compliance": { + "title": "Dina data lämnar aldrig din enhet", + "weKeep": "Vi håller", + "yourInfoSafe": "din information säker", + "byFollowingStandards": "genom att följa globala säkerhetsstandarder.", + "processingLocal": "All bearbetning sker lokalt på din enhet.", + "gdpr": { + "title": "GDPR-efterlevnad", + "description": "Skyddar personuppgifter och integritet för individer inom Europeiska unionen." + }, + "ccpa": { + "title": "CCPA-efterlevnad", + "description": "Ger Kaliforniens residenter rättigheter över hur deras personliga information samlas in, används och delas." + }, + "hipaa": { + "title": "HIPAA-efterlevnad", + "description": "Fastställer skyddsåtgärder för hantering av känslig hälsoinformation i det amerikanska sjukvårdssystemet." + } + }, + "faq": { + "title": "Vanliga", + "questions": "frågor", + "sectionTitle": "Vanliga frågor och svar", + "isFree": { + "question": "Är BentoPDF verkligen gratis?", + "answer": "Ja, absolut. Alla verktyg på BentoPDF är 100% gratis att använda, utan filgränser, utan registreringar och utan vattenstämplar. Vi tror att alla förtjänar tillgång till enkla, kraftfulla PDF-verktyg utan en betalvägg." + }, + "areFilesSecure": { + "question": "Är mina filer säkra? Var bearbetas de?", + "answer": "Dina filer är så säkra som möjligt eftersom de aldrig lämnar din dator. All bearbetning sker direkt i din webbläsare (klientsida). Vi laddar aldrig upp dina filer till en server, så du har fullständig integritet och kontroll över dina dokument." + }, + "platforms": { + "question": "Fungerar det på Mac, Windows och mobil?", + "answer": "Ja! Eftersom BentoPDF körs helt i din webbläsare fungerar det på alla operativsystem med en modern webbläsare, inklusive Windows, macOS, Linux, iOS och Android." + }, + "gdprCompliant": { + "question": "Är BentoPDF GDPR-kompatibelt?", + "answer": "Ja. BentoPDF är fullt GDPR-kompatibelt. Eftersom all filbearbetning sker lokalt i din webbläsare och vi aldrig samlar in eller överför dina filer till någon server, har vi ingen tillgång till dina data. Detta säkerställer att du alltid har kontroll över dina dokument." + }, + "dataStorage": { + "question": "Lagrar eller spårar ni några av mina filer?", + "answer": "Nej. Vi lagrar, spårar eller loggar aldrig dina filer. Allt du gör på BentoPDF händer i din webbläsares minne och försvinner när du stänger sidan. Det finns inga uppladdningar, inga historikloggar och inga servrar inblandade." + }, + "different": { + "question": "Vad gör BentoPDF annorlunda från andra PDF-verktyg?", + "answer": "De flesta PDF-verktyg laddar upp dina filer till en server för bearbetning. BentoPDF gör aldrig det. Vi använder säker, modern webbteknik för att bearbeta dina filer direkt i din webbläsare. Detta innebär snabbare prestanda, starkare integritet och fullständig trygghet." + }, + "browserBased": { + "question": "Hur håller webbläsarbaserad bearbetning mig säker?", + "answer": "Genom att köras helt inne i din webbläsare säkerställer BentoPDF att dina filer aldrig lämnar din enhet. Detta eliminerar riskerna för serverhack, dataintrång eller obehörig åtkomst. Dina filer förblir dina - alltid." + }, + "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." + } + }, + "testimonials": { + "title": "Vad våra", + "users": "användare", + "say": "säger" + }, + "support": { + "title": "Gillar du mitt arbete?", + "description": "BentoPDF är ett passionerat projekt, byggt för att erbjuda en gratis, privat och kraftfull PDF-verktygslåda för alla. Om du tycker det är användbart, överväg att stödja utvecklingen. Varje kaffe hjälper!", + "buyMeCoffee": "Buy Me a Coffee" + }, + "footer": { + "copyright": "© 2026 BentoPDF. Alla rättigheter reserverade.", + "version": "Version", + "company": "Företag", + "aboutUs": "Om oss", + "faqLink": "FAQ", + "contactUs": "Kontakta oss", + "legal": "Juridiskt", + "termsAndConditions": "Villkor", + "privacyPolicy": "Integritetspolicy", + "followUs": "Följ oss" + }, + "merge": { + "title": "Sammanfoga PDF:er", + "description": "Kombinera hela filer eller välj specifika sidor att sammanfoga till ett nytt dokument.", + "fileMode": "Filläge", + "pageMode": "Sidoläge", + "howItWorks": "Så här fungerar det:", + "fileModeInstructions": [ + "Klicka och dra ikonen för att ändra ordningen på filerna.", + "I rutan \"Sidor\" för varje fil kan du ange intervall (t.ex. \"1-3, 5\") för att sammanfoga endast de sidorna.", + "Lämna rutan \"Sidor\" tom för att inkludera alla sidor från den filen." + ], + "pageModeInstructions": [ + "Alla sidor från dina uppladdade PDF:er visas nedan.", + "Dra och släpp sidminiyrbilderna för att skapa den ordning du vill ha för din nya fil." + ], + "mergePdfs": "Sammanfoga PDF:er" + }, + "common": { + "page": "Sida", + "pages": "Sidor", + "of": "av", + "download": "Ladda ner", + "cancel": "Avbryt", + "save": "Spara", + "delete": "Ta bort", + "edit": "Redigera", + "add": "Lägg till", + "remove": "Ta bort", + "loading": "Laddar...", + "error": "Fel", + "success": "Klart", + "file": "Fil", + "files": "Filer", + "close": "Stäng" + }, + "about": { + "hero": { + "title": "Vi tror att PDF-verktyg ska vara", + "subtitle": "snabba, privata och gratis.", + "noCompromises": "Inga kompromisser." + }, + "mission": { + "title": "Vårt uppdrag", + "description": "Att tillhandahålla den mest omfattande PDF-verktygslådan som respekterar din integritet och aldrig ber om betalning. Vi tycker att viktiga dokumentverktyg ska vara tillgängliga för alla, överallt, utan hinder." + }, + "philosophy": { + "label": "Vår kärnfilosofi", + "title": "Integritet först. Alltid.", + "description": "I en tid där data är en handelsvara tar vi en annan väg. All bearbetning i BentoPDF sker lokalt i din webbläsare. Detta dina filer betyder att aldrig rör våra servrar, vi ser aldrig dina dokument och vi spårar inte vad du gör. Dina dokument förblir helt och hållet privata. Det är inte bara en funktion - det är vår grund." + }, + "whyBentopdf": { + "title": "Varför", + "speed": { + "title": "Byggd för hastighet", + "description": "Ingen väntan på uppladdningar eller nedladdningar till en server. Genom att bearbeta filer direkt i din webbläsare med moderna webbteknologier som WebAssembly erbjuder vi oöverträffad hastighet för alla våra verktyg." + }, + "free": { + "title": "Helt gratis", + "description": "Inga provperioder, inga prenumerationer, inga dolda avgifter och inga \"premium\"-funktioner bakom en spärr. Vi tycker att kraftfulla PDF-verktyg ska vara en allmän nyttighet, inte en vinstmaskin." + }, + "noAccount": { + "title": "Inget konto krävs", + "description": "Börja använda vilket verktyg som helst direkt. Vi behöver inte din e-post, ett lösenord eller någon personlig information. Ditt arbetsflöde ska vara friktionsfritt och anonymt." + }, + "openSource": { + "title": "Öppen källkod-anda", + "description": "Byggd med transparens i åtanke. Vi använder fantastiska öppna källkodsbibliotek som PDF-lib och PDF.js, och tror på community-driven utveckling för att göra kraftfulla verktyg tillgängliga för alla." + } + }, + "cta": { + "title": "Redo att komma igång?", + "description": "Gå med i tusentals användare som litar på Bentopdf för sina dagliga dokumentbehov. Upplev skillnaden som integritet och prestanda kan göra.", + "button": "Utforska alla verktyg" + } + }, + "contact": { + "title": "Kontakta oss", + "subtitle": "Vi vill gärna höra från dig. Oavsett om du har frågor, feedback eller funktionsförslag - tveka inte att höra av dig.", + "email": "Du kan nå oss direkt via e-post på:" + }, + "licensing": { + "title": "Licenser för", + "subtitle": "Välj den licens som passar dina behov." + }, + "multiTool": { + "uploadPdfs": "Ladda upp PDF:er", + "upload": "Ladda upp", + "addBlankPage": "Lägg till tom sida", + "edit": "Redigera:", + "undo": "Ångra", + "redo": "Gör om", + "reset": "Återställ", + "selection": "Markering:", + "selectAll": "Markera alla", + "deselectAll": "Avmarkera alla", + "rotate": "Rotera:", + "rotateLeft": "Vänster", + "rotateRight": "Höger", + "transform": "Transformera:", + "duplicate": "Duplicera", + "split": "Dela", + "clear": "Rensa:", + "delete": "Ta bort", + "download": "Ladda ner:", + "downloadSelected": "Ladda ner markerade", + "exportPdf": "Exportera PDF", + "uploadPdfFiles": "Välj PDF-filer", + "dragAndDrop": "Dra och släpp PDF-filer här, eller klicka för att välja", + "selectFiles": "Välj filer", + "renderingPages": "Renderar sidor...", + "actions": { + "duplicatePage": "Duplicera denna sida", + "deletePage": "Ta bort denna sida", + "insertPdf": "Infoga PDF efter denna sida", + "toggleSplit": "Växla delning efter denna sida" + }, + "pleaseWait": "Vänligen vänta", + "pagesRendering": "Sidor renderas fortfarande. Vänligen vänta...", + "noPagesSelected": "Inga sidor markerade", + "selectOnePage": "Vänligen välj minst en sida att ladda ner.", + "noPages": "Inga sidor", + "noPagesToExport": "Det finns inga sidor att exportera.", + "renderingTitle": "Renderar sidförhandsvisningar", + "errorRendering": "Det gick inte att rendera sidminiatyrer", + "error": "Fel", + "failedToLoad": "Det gick inte att ladda" + }, + "simpleMode": { + "title": "PDF-verktyg", + "subtitle": "Välj ett verktyg för att komma igång" + } +} diff --git a/public/locales/sv/tools.json b/public/locales/sv/tools.json new file mode 100644 index 0000000..2f02976 --- /dev/null +++ b/public/locales/sv/tools.json @@ -0,0 +1,671 @@ +{ + "categories": { + "popularTools": "Populära verktyg", + "editAnnotate": "Redigera & Kommentera", + "convertToPdf": "Konvertera till PDF", + "convertFromPdf": "Konvertera från PDF", + "organizeManage": "Organisera & Hantera", + "optimizeRepair": "Optimera & Reparera", + "securePdf": "Säkra PDF" + }, + "pdfMultiTool": { + "name": "PDF multiverktyg", + "subtitle": "Sammanfoga, dela, organisera, ta bort, rotera, lägg till tomma sidor, extrahera och duplicera i ett enhetligt gränssnitt." + }, + "mergePdf": { + "name": "Sammanfoga PDF", + "subtitle": "Kombinera flera PDF-filer till en fil. Bevarar bokmärken." + }, + "splitPdf": { + "name": "Dela PDF", + "subtitle": "Extrahera ett intervall av sidor till en ny PDF." + }, + "compressPdf": { + "name": "Komprimera PDF", + "subtitle": "Minska filstorleken på din PDF.", + "algorithmLabel": "Komprimeringsalgoritm", + "condense": "Kondensera (Rekommenderas)", + "photon": "Foton (För foto-tunga PDF:er)", + "condenseInfo": "Kondensera använder avancerad komprimering: tar bort död vikt, optimerar bilder, undergrupperar typsnitt. Bäst för de flesta PDF:er.", + "photonInfo": "Foton konverterar sidor till bilder. Använd för foto-tunga/scannade PDF:er.", + "photonWarning": "Varning: Text blir icke-valbar och länkarna slutar fungera.", + "levelLabel": "Komprimeringsnivå", + "light": "Lätt (Bevara kvalitet)", + "balanced": "Balanserad (Rekommenderas)", + "aggressive": "Aggressiv (Mindre filer)", + "extreme": "Extreme (Maximal komprimering)", + "grayscale": "Konvertera till gråskala", + "grayscaleHint": "Minskar filstorleken genom att ta bort färginformation", + "customSettings": "Anpassade inställningar", + "customSettingsHint": "Finjustera komprimeringsparametrar:", + "outputQuality": "Utdatakvalitet", + "resizeImagesTo": "Ändra bildstorlek till", + "onlyProcessAbove": "Bearbeta endast över", + "removeMetadata": "Ta bort metadata", + "subsetFonts": "Undergruppera typsnitt (ta bort oanvända glyfer)", + "removeThumbnails": "Ta bort inbäddade miniatyrer", + "compressButton": "Komprimera PDF" + }, + "pdfEditor": { + "name": "PDF-redigerare", + "subtitle": "Kommentera, markera, redigera, kommentera, lägg till former/bilder, sök och visa PDF:er." + }, + "jpgToPdf": { + "name": "JPG till PDF", + "subtitle": "Skapa en PDF från JPG, JPEG och JPEG2000 (JP2/JPX)-bilder." + }, + "signPdf": { + "name": "Signera PDF", + "subtitle": "Rita, skriv eller ladda upp din signatur." + }, + "cropPdf": { + "name": "Beskär PDF", + "subtitle": "Beskär marginalerna på varje sida i din PDF." + }, + "extractPages": { + "name": "Extrahera sidor", + "subtitle": "Spara ett urval av sidor som nya filer." + }, + "duplicateOrganize": { + "name": "Duplicera & organisera", + "subtitle": "Duplicera, ordna om och ta bort sidor." + }, + "deletePages": { + "name": "Ta bort sidor", + "subtitle": "Ta bort specifika sidor från ditt dokument." + }, + "editBookmarks": { + "name": "Redigera bokmärken", + "subtitle": "Lägg till, redigera, importera, ta bort och extrahera PDF-bokmärken." + }, + "tableOfContents": { + "name": "Innehållsförteckning", + "subtitle": "Generera en innehållsförteckningssida från PDF-bokmärken." + }, + "pageNumbers": { + "name": "Sidnummer", + "subtitle": "Infoga sidnummer i ditt dokument." + }, + "batesNumbering": { + "name": "Bates-numrering", + "subtitle": "Lägg till sekventiella Bates-nummer över en eller flera PDF-filer." + }, + "addWatermark": { + "name": "Lägg till vattenstämpel", + "subtitle": "Stämpla text eller en bild över dina PDF-sidor.", + "applyToAllPages": "Applicera på alla sidor" + }, + "headerFooter": { + "name": "Sidhuvud & sidfot", + "subtitle": "Lägg till text överst och längst ner på sidor." + }, + "invertColors": { + "name": "Invertera färger", + "subtitle": "Skapa en \"mörkt läge\"-version av din PDF." + }, + "scannerEffect": { + "name": "Skannereffekt", + "subtitle": "Få din PDF att se ut som ett scannat dokument.", + "scanSettings": "Skannerinställningar", + "colorspace": "Färgrymd", + "gray": "Grå", + "border": "Kant", + "rotate": "Rotera", + "rotateVariance": "Rotationsvarians", + "brightness": "Ljushet", + "contrast": "Kontrast", + "blur": "Oskärpa", + "noise": "Brus", + "yellowish": "Gulaktig", + "resolution": "Upplösning", + "processButton": "Applicera skannereffekt" + }, + "adjustColors": { + "name": "Justera färger", + "subtitle": "Finjustera ljusstyrka, kontrast, mättnad och mer i din PDF.", + "colorSettings": "Färginställningar", + "brightness": "Ljushet", + "contrast": "Kontrast", + "saturation": "Mättnad", + "hueShift": "Nyansskift", + "temperature": "Temperatur", + "tint": "Ton", + "gamma": "Gamma", + "sepia": "Sepia", + "processButton": "Applicera färgjusteringar" + }, + "backgroundColor": { + "name": "Bakgrundsfärg", + "subtitle": "Ändra bakgrundsfärgen på din PDF." + }, + "changeTextColor": { + "name": "Ändra textfärg", + "subtitle": "Ändra färgen på text i din PDF." + }, + "addStamps": { + "name": "Lägg till stämplar", + "subtitle": "Lägg till bildstämplar på din PDF med kommentarsverktygsfältet.", + "usernameLabel": "Stämpelanvändarnamn", + "usernamePlaceholder": "Ange ditt namn (för stämplar)", + "usernameHint": "Detta namn kommer att visas på stämplar du skapar." + }, + "removeAnnotations": { + "name": "Ta bort anteckningar", + "subtitle": "Ta bort kommentarer, markeringar och länkade." + }, + "pdfFormFiller": { + "name": "PDF-formulärifyllare", + "subtitle": "Fyll i formulär direkt i webbläsaren. Stöder även XFA-formulär." + }, + "createPdfForm": { + "name": "Skapa PDF-formulär", + "subtitle": "Skapa ifyllbara PDF-formulär med dra-och-släpp textfält." + }, + "removeBlankPages": { + "name": "Ta bort tomma sidor", + "subtitle": "Automatiskt identifiera och ta bort tomma sidor.", + "sensitivityHint": "Högre = striktare, bara helt tomma sidor. Lägre = tillåter sidor med visst innehåll." + }, + "imageToPdf": { + "name": "Bilder till PDF", + "subtitle": "Konvertera JPG, PNG, BMP, GIF, TIFF, PNM, PGM, PBM, PPM, PAM, JXR, JPX, JP2, PSD, SVG, HEIC, WebP till PDF." + }, + "pngToPdf": { + "name": "PNG till PDF", + "subtitle": "Skapa en PDF från en eller flera PNG-bilder." + }, + "webpToPdf": { + "name": "WebP till PDF", + "subtitle": "Skapa en PDF från en eller flera WebP-bilder." + }, + "svgToPdf": { + "name": "SVG till PDF", + "subtitle": "Skapa en PDF från en eller flera SVG-bilder." + }, + "bmpToPdf": { + "name": "BMP till PDF", + "subtitle": "Skapa en PDF från en eller flera BMP-bilder." + }, + "heicToPdf": { + "name": "HEIC till PDF", + "subtitle": "Skapa en PDF från en eller flera HEIC-bilder." + }, + "tiffToPdf": { + "name": "TIFF till PDF", + "subtitle": "Skapa en PDF från en eller flera TIFF-bilder." + }, + "textToPdf": { + "name": "Text till PDF", + "subtitle": "Konvertera en vanlig textfil till en PDF." + }, + "jsonToPdf": { + "name": "JSON till PDF", + "subtitle": "Konvertera JSON-filer till PDF-format." + }, + "pdfToJpg": { + "name": "PDF till JPG", + "subtitle": "Konvertera varje PDF-sida till en JPG-bild." + }, + "pdfToPng": { + "name": "PDF till PNG", + "subtitle": "Konvertera varje PDF-sida till en PNG-bild." + }, + "pdfToWebp": { + "name": "PDF till WebP", + "subtitle": "Konvertera varje PDF-sida till en WebP-bild." + }, + "pdfToBmp": { + "name": "PDF till BMP", + "subtitle": "Konvertera varje PDF-sida till en BMP-bild." + }, + "pdfToTiff": { + "name": "PDF till TIFF", + "subtitle": "Konvertera varje PDF-sida till en TIFF-bild." + }, + "pdfToGreyscale": { + "name": "PDF till gråskala", + "subtitle": "Konvertera alla färger till svart och vitt." + }, + "pdfToJson": { + "name": "PDF till JSON", + "subtitle": "Konvertera PDF-filer till JSON-format." + }, + "ocrPdf": { + "name": "OCR PDF", + "subtitle": "Gör en PDF sökbar och kopiaerbar." + }, + "alternateMix": { + "name": "Alternera & blanda sidor", + "subtitle": "Sammanfoga PDF:er genom att alternera sidor från varje PDF. Bevarar bokmärken." + }, + "addAttachments": { + "name": "Lägg till bilagor", + "subtitle": "Bädda in en eller flera filer i din PDF." + }, + "extractAttachments": { + "name": "Extrahera bilagor", + "subtitle": "Extrahera alla inbäddade filer från PDF(er) som en ZIP." + }, + "editAttachments": { + "name": "Redigera bilagor", + "subtitle": "Visa eller ta bort bilagor i din PDF." + }, + "dividePages": { + "name": "Dela sidor", + "subtitle": "Dela sidor horisontellt eller vertikalt." + }, + "addBlankPage": { + "name": "Lägg till tom sida", + "subtitle": "Infoga en tom sida var som helst i din PDF." + }, + "reversePages": { + "name": "Omvänd sidor", + "subtitle": "Vänd ordningen på alla sidor i ditt dokument." + }, + "rotatePdf": { + "name": "Rotera PDF", + "subtitle": "Vrid sidor i 90-graderssteg." + }, + "rotateCustom": { + "name": "Rotera efter anpassade grader", + "subtitle": "Rotera sidor efter valfri vinkel." + }, + "nUpPdf": { + "name": "N-Up PDF", + "subtitle": "Ordna flera sidor på ett enda ark." + }, + "combineToSinglePage": { + "name": "Kombinera till enskild sida", + "subtitle": "Sy ihop alla sidor till en kontinuerlig rullning." + }, + "viewMetadata": { + "name": "Visa metadata", + "subtitle": "Granska de dolda egenskaperna i din PDF." + }, + "editMetadata": { + "name": "Redigera metadata", + "subtitle": "Ändra författaren, titeln och andra egenskaper." + }, + "pdfsToZip": { + "name": "PDF:er till ZIP", + "subtitle": "Paketera flera PDF-filer till ett ZIP-arkiv." + }, + "comparePdfs": { + "name": "Jämför PDF:er", + "subtitle": "Jämför två PDF:er bredvid varandra.", + "firstPdf": "Första PDF", + "secondPdf": "Andra PDF", + "clickOrDrop": "Klicka eller släpp", + "page": "Sida", + "overlay": "Överlägg", + "sideBySide": "Sida vid sida", + "flicker": "Flimmer", + "syncScroll": "Synkronisera rullning", + "export": "Exportera", + "exportAsPdf": "Exportera som PDF", + "splitView": "Delad vy", + "alternating": "Växlande", + "leftDocument": "Vänster dokument", + "rightDocument": "Höger dokument", + "original": "Original", + "modified": "Ändrad", + "searchChanges": "Sök ändringar...", + "deleted": "Borttagen", + "added": "Tillagd", + "prevPage": "Föregående sida", + "nextPage": "Nästa sida", + "prevChange": "Föregående ändring", + "nextChange": "Nästa ändring", + "uploadTwoPdfs": "Ladda upp två PDF:er för att se skillnaderna.", + "noDifferences": "Inga skillnader upptäcktes på denna sida.", + "noMatchingChanges": "Inga ändringar matchar det aktuella filtret.", + "pageNotExist": "Sidan {{page}} finns inte i denna PDF.", + "noPairedPage": "Ingen matchad sida för denna sida.", + "buildingModel": "Bygger sidparningsmodell...", + "indexingPdf": "Indexerar PDF {{num}}, sida {{page}} av {{total}}...", + "loadingComparison": "Läser in jämförelse {{current}} av {{total}}...", + "runningOcr": "Kör OCR på sida {{page}}...", + "preparingExport": "Förbereder PDF-export...", + "renderingPage": "Renderar sida {{current}} av {{total}}...", + "exportError": "Exportfel", + "exportFailed": "Kunde inte exportera jämförelse-PDF.", + "loadingFile": "Läser in {{name}}...", + "invalidFile": "Ogiltig fil", + "invalidFileMsg": "Välj en giltig PDF-fil.", + "loadError": "Kunde inte läsa in PDF. Den kan vara skadad eller lösenordsskyddad." + }, + "posterizePdf": { + "name": "Postera PDF", + "subtitle": "Dela en stor sida i flera mindre sidor." + }, + "fixPageSize": { + "name": "Fixa sidstorlek", + "subtitle": "Standardisera alla sidor till en enhetlig storlek." + }, + "linearizePdf": { + "name": "Linearizera PDF", + "subtitle": "Optimera PDF för snabb webbvisning." + }, + "pageDimensions": { + "name": "Sidmått", + "subtitle": "Analysera sidstorlek, orientering och enheter." + }, + "removeRestrictions": { + "name": "Ta bort begränsningar", + "subtitle": "Ta bort lösenordsskydd och säkerhetsbegränsningar associerade med digitalt signerade PDF-filer." + }, + "repairPdf": { + "name": "Reparera PDF", + "subtitle": "Återställ data från skadade eller korrupta PDF-filer." + }, + "encryptPdf": { + "name": "Kryptera PDF", + "subtitle": "Lås din PDF genom att lägga till ett lösenord." + }, + "sanitizePdf": { + "name": "Sanera PDF", + "subtitle": "Ta bort metadata, anteckningar, skript och mer." + }, + "decryptPdf": { + "name": "Dekryptera PDF", + "subtitle": "Lås upp PDF genom att ta bort lösenordsskydd." + }, + "flattenPdf": { + "name": "Platta till PDF", + "subtitle": "Gör formulärfält och anteckningar icke-redigerbara." + }, + "removeMetadata": { + "name": "Ta bort metadata", + "subtitle": "Ta bort dold data från din PDF." + }, + "changePermissions": { + "name": "Ändra behörigheter", + "subtitle": "Ställ in eller ändra användarbehörigheter på en PDF." + }, + "odtToPdf": { + "name": "ODT till PDF", + "subtitle": "Konvertera OpenDocument Text-filer till PDF-format. Stöder flera filer.", + "acceptedFormats": "ODT-filer", + "convertButton": "Konvertera till PDF" + }, + "csvToPdf": { + "name": "CSV till PDF", + "subtitle": "Konvertera CSV-kalkylarksfiler till PDF-format. Stöder flera filer.", + "acceptedFormats": "CSV-filer", + "convertButton": "Konvertera till PDF" + }, + "rtfToPdf": { + "name": "RTF till PDF", + "subtitle": "Konvertera Rich Text Format-dokument till PDF. Stöder flera filer.", + "acceptedFormats": "RTF-filer", + "convertButton": "Konvertera till PDF" + }, + "wordToPdf": { + "name": "Word till PDF", + "subtitle": "Konvertera Word-dokument (DOCX, DOC, ODT, RTF) till PDF-format. Stöder flera filer.", + "acceptedFormats": "DOCX, DOC, ODT, RTF-filer", + "convertButton": "Konvertera till PDF" + }, + "excelToPdf": { + "name": "Excel till PDF", + "subtitle": "Konvertera Excel-kalkylark (XLSX, XLS, ODS, CSV) till PDF-format. Stöder flera filer.", + "acceptedFormats": "XLSX, XLS, ODS, CSV-filer", + "convertButton": "Konvertera till PDF" + }, + "powerpointToPdf": { + "name": "PowerPoint till PDF", + "subtitle": "Konvertera PowerPoint-presentationer (PPTX, PPT, ODP) till PDF-format. Stöder flera filer.", + "acceptedFormats": "PPTX, PPT, ODP-filer", + "convertButton": "Konvertera till PDF" + }, + "markdownToPdf": { + "name": "Markdown till PDF", + "subtitle": "Skriv eller klistra in Markdown och exportera som en vackert formaterad PDF.", + "paneMarkdown": "Markdown", + "panePreview": "Förhandsvisning", + "btnUpload": "Ladda upp", + "btnSyncScroll": "Synkronisera scroll", + "btnSettings": "Inställningar", + "btnExportPdf": "Exportera PDF", + "settingsTitle": "Markdown-inställningar", + "settingsPreset": "Förinställning", + "presetDefault": "Standard (GFM-liknande)", + "presetCommonmark": "CommonMark (strikt)", + "presetZero": "Minimal (inga funktioner)", + "settingsOptions": "Markdown-alternativ", + "optAllowHtml": "Tillåt HTML-taggar", + "optBreaks": "Konvertera radbrytningar till
", + "optLinkify": "Auto-konvertera URL:er till länkade", + "optTypographer": "Typograf (smarta citattecken, etc.)" + }, + "pdfBooklet": { + "name": "PDF-broschyr", + "subtitle": "Ordna om sidor för dubbelsidig broschyutskrift. Vik och häfta för att skapa en broschyr.", + "howItWorks": "Så här fungerar det:", + "step1": "Ladda upp en PDF-fil.", + "step2": "Sidor kommer att ordnas om i broschyrordning.", + "step3": "Skriv ut dubbelsidigt, vik på kort kant, vik och häfta.", + "paperSize": "Pappersstorlek", + "orientation": "Orientering", + "portrait": "Stående", + "landscape": "Liggande", + "pagesPerSheet": "Sidor per ark", + "createBooklet": "Skapa broschyr", + "processing": "Bearbetar...", + "pageCount": "Sidantal kommer att fyllas ut till multipel av 4 om det behövs." + }, + "xpsToPdf": { + "name": "XPS till PDF", + "subtitle": "Konvertera XPS/OXPS-dokument till PDF-format. Stöder flera filer.", + "acceptedFormats": "XPS, OXPS-filer", + "convertButton": "Konvertera till PDF" + }, + "mobiToPdf": { + "name": "MOBI till PDF", + "subtitle": "Konvertera MOBI e-böcker till PDF-format. Stöder flera filer.", + "acceptedFormats": "MOBI-filer", + "convertButton": "Konvertera till PDF" + }, + "epubToPdf": { + "name": "EPUB till PDF", + "subtitle": "Konvertera EPUB e-böcker till PDF-format. Stöder flera filer.", + "acceptedFormats": "EPUB-filer", + "convertButton": "Konvertera till PDF" + }, + "fb2ToPdf": { + "name": "FB2 till PDF", + "subtitle": "Konvertera FictionBook (FB2) e-böcker till PDF-format. Stöder flera filer.", + "acceptedFormats": "FB2-filer", + "convertButton": "Konvertera till PDF" + }, + "cbzToPdf": { + "name": "CBZ till PDF", + "subtitle": "Konvertera serietidshäften (CBZ/CBR) till PDF-format. Stöder flera filer.", + "acceptedFormats": "CBZ, CBR-filer", + "convertButton": "Konvertera till PDF" + }, + "wpdToPdf": { + "name": "WPD till PDF", + "subtitle": "Konvertera WordPerfect-dokument (WPD) till PDF-format. Stöder flera filer.", + "acceptedFormats": "WPD-filer", + "convertButton": "Konvertera till PDF" + }, + "wpsToPdf": { + "name": "WPS till PDF", + "subtitle": "Konvertera WPS Office-dokument till PDF-format. Stöder flera filer.", + "acceptedFormats": "WPS-filer", + "convertButton": "Konvertera till PDF" + }, + "xmlToPdf": { + "name": "XML till PDF", + "subtitle": "Konvertera XML-dokument till PDF-format. Stöder flera filer.", + "acceptedFormats": "XML-filer", + "convertButton": "Konvertera till PDF" + }, + "pagesToPdf": { + "name": "Pages till PDF", + "subtitle": "Konvertera Apple Pages-dokument till PDF-format. Stöder flera filer.", + "acceptedFormats": "Pages-filer", + "convertButton": "Konvertera till PDF" + }, + "odgToPdf": { + "name": "ODG till PDF", + "subtitle": "Konvertera OpenDocument Graphics (ODG)-filer till PDF-format. Stöder flera filer.", + "acceptedFormats": "ODG-filer", + "convertButton": "Konvertera till PDF" + }, + "odsToPdf": { + "name": "ODS till PDF", + "subtitle": "Konvertera OpenDocument Spreadsheet (ODS)-filer till PDF-format. Stöder flera filer.", + "acceptedFormats": "ODS-filer", + "convertButton": "Konvertera till PDF" + }, + "odpToPdf": { + "name": "ODP till PDF", + "subtitle": "Konvertera OpenDocument Presentation (ODP)-filer till PDF-format. Stöder flera filer.", + "acceptedFormats": "ODP-filer", + "convertButton": "Konvertera till PDF" + }, + "pubToPdf": { + "name": "PUB till PDF", + "subtitle": "Konvertera Microsoft Publisher (PUB)-filer till PDF-format. Stöder flera filer.", + "acceptedFormats": "PUB-filer", + "convertButton": "Konvertera till PDF" + }, + "vsdToPdf": { + "name": "VSD till PDF", + "subtitle": "Konvertera Microsoft Visio (VSD, VSDX)-filer till PDF-format. Stöder flera filer.", + "acceptedFormats": "VSD, VSDX-filer", + "convertButton": "Konvertera till PDF" + }, + "psdToPdf": { + "name": "PSD till PDF", + "subtitle": "Konvertera Adobe Photoshop (PSD)-filer till PDF-format. Stöder flera filer.", + "acceptedFormats": "PSD-filer", + "convertButton": "Konvertera till PDF" + }, + "pdfToSvg": { + "name": "PDF till SVG", + "subtitle": "Konvertera varje sida i en PDF till en skalbar vektorgrafik (SVG) för perfekt kvalitet i valfri storlek." + }, + "extractTables": { + "name": "Extrahera PDF-tabeller", + "subtitle": "Extrahera tabeller från PDF-filer och exportera som CSV, JSON eller Markdown." + }, + "pdfToCsv": { + "name": "PDF till CSV", + "subtitle": "Extrahera tabeller från PDF och konvertera till CSV-format." + }, + "pdfToExcel": { + "name": "PDF till Excel", + "subtitle": "Extrahera tabeller från PDF och konvertera till Excel (XLSX)-format." + }, + "pdfToText": { + "name": "PDF till text", + "subtitle": "Extrahera text från PDF-filer och spara som vanlig text (.txt). Stöder flera filer.", + "note": "Detta verktyg fungerar ENDAST med digitalt skapade PDF:er. För scannade dokument eller bildbaserade PDF:er, använd vårt OCR PDF-verktyg istället.", + "convertButton": "Extrahera text" + }, + "digitalSignPdf": { + "name": "Digital signatur PDF", + "pageTitle": "Digital signatur PDF - Lägg till kryptografisk signatur | BentoPDF", + "subtitle": "Lägg till en kryptografisk digital signatur på din PDF med X.509-certifikat. Stöder PKCS#12 (.pfx, .p12) och PEM-format. Din privata nyckel lämnar aldrig din webbläsare.", + "certificateSection": "Certifikat", + "uploadCert": "Ladda upp certifikat (.pfx, .p12)", + "certPassword": "Certifikatlösenord", + "certPasswordPlaceholder": "Ange certifikatlösenord", + "certInfo": "Certifikatinformation", + "certSubject": "Ämne", + "certIssuer": "Utfärdare", + "certValidity": "Giltig", + "signatureDetails": "Signaturdetaljer (Valfritt)", + "reason": "Anledning", + "reasonPlaceholder": "t.ex. Jag godkänner detta dokument", + "location": "Plats", + "locationPlaceholder": "t.ex. Stockholm, Sverige", + "contactInfo": "Kontaktinformation", + "contactPlaceholder": "t.ex. email@example.com", + "applySignature": "Applicera digital signatur", + "successMessage": "PDF signerad framgångsrikt! Signaturen kan verifieras i vilken PDF-läsare som helst." + }, + "validateSignaturePdf": { + "name": "Validera PDF-signatur", + "pageTitle": "Validera PDF-signatur - Verifiera digitala signaturer | BentoPDF", + "subtitle": "Verifiera digitala signaturer i dina PDF-filer. Kontrollera certifikatgiltighet, visa signerardetaljer och bekräfta dokumentintegritet. All bearbetning sker i din webbläsare." + }, + "emailToPdf": { + "name": "E-post till PDF", + "subtitle": "Konvertera e-postfiler (EML, MSG) till PDF-format. Stöder Outlook-exporter och standard e-postformat.", + "acceptedFormats": "EML, MSG-filer", + "convertButton": "Konvertera till PDF" + }, + "fontToOutline": { + "name": "Typsnitt till kontur", + "subtitle": "Konvertera alla typsnitt till vektorkonturer för konsekvent rendering på alla enheter." + }, + "deskewPdf": { + "name": "Räta upp PDF", + "subtitle": "Automatiskt räta upp snedvinklade scannade sidor med OpenCV." + }, + "pdfToWord": { + "name": "PDF till Word", + "subtitle": "Konvertera PDF-filer till redigerbara Word-dokument." + }, + "extractImages": { + "name": "Extrahera bilder", + "subtitle": "Extrahera alla inbäddade bilder från dina PDF-filer." + }, + "pdfToMarkdown": { + "name": "PDF till Markdown", + "subtitle": "Konvertera PDF-text och tabeller till Markdown-format." + }, + "preparePdfForAi": { + "name": "Förbered PDF för AI", + "subtitle": "Extrahera PDF-innehåll som LlamaIndex JSON för RAG/LLM-pipelines." + }, + "pdfOcg": { + "name": "PDF OCG", + "subtitle": "Visa, växla, lägg till och ta bort OCG-lager i din PDF." + }, + "pdfToPdfa": { + "name": "PDF till PDF/A", + "subtitle": "Konvertera PDF till PDF/A för långtidsarkivering." + }, + "rasterizePdf": { + "name": "Rasterisera PDF", + "subtitle": "Konvertera PDF till bildbaserad PDF. Platta till lager och ta bort valbar text." + }, + "pdfWorkflow": { + "name": "PDF-arbetsflödesbyggare", + "subtitle": "Bygg anpassade PDF-bearbetningspipelines med en visuell nodredigerare.", + "nodes": "Noder", + "searchNodes": "Sök noder...", + "run": "Kör", + "clear": "Rensa", + "save": "Spara", + "load": "Ladda", + "export": "Exportera", + "import": "Importera", + "ready": "Redo", + "settings": "Inställningar", + "processing": "Bearbetar...", + "saveTemplate": "Spara mall", + "templateName": "Mallnamn", + "templatePlaceholder": "t.ex. Faktura-arbetsflöde", + "cancel": "Avbryt", + "loadTemplate": "Ladda mall", + "noTemplates": "Inga sparade mallar ännu.", + "ok": "OK", + "workflowCompleted": "Arbetsflöde klart", + "errorDuringExecution": "Fel under körning", + "addNodeError": "Lägg till minst en nod för att köra arbetsflödet.", + "needInputOutput": "Ditt arbetsflöde behöver minst en inmatningsnod och en utmatningsnod för att köra.", + "enterName": "Vänligen ange ett namn.", + "templateExists": "En mall med detta namn finns redan.", + "templateSaved": "Mallen \"{{name}}\" sparad.", + "templateLoaded": "Mallen \"{{name}}\" laddad.", + "failedLoadTemplate": "Det gick inte att ladda mallen.", + "noSettings": "Inga konfigurerbara inställningar för denna nod.", + "advancedSettings": "Avancerade inställningar" + } +} diff --git a/public/locales/tr/common.json b/public/locales/tr/common.json index 1c874d4..ca6fceb 100644 --- a/public/locales/tr/common.json +++ b/public/locales/tr/common.json @@ -66,7 +66,34 @@ "pdfOrImages": "PDF veya Görseller", "filesNeverLeave": "Dosyalarınız cihazınızı asla terk etmez.", "addMore": "Daha Fazla Dosya Ekle", - "clearAll": "Tümünü Temizle" + "clearAll": "Tümünü Temizle", + "clearFiles": "Dosyaları temizle", + "hints": { + "singlePdf": "Tek bir PDF dosyası", + "pdfFile": "PDF dosyası", + "multiplePdfs2": "Birden fazla PDF dosyası (en az 2)", + "bmpImages": "BMP görüntüler", + "oneOrMorePdfs": "Bir veya daha fazla PDF dosyası", + "pdfDocuments": "PDF belgeleri", + "oneOrMoreCsv": "Bir veya daha fazla CSV dosyası", + "multiplePdfsSupported": "Birden fazla PDF dosyası desteklenir", + "singleOrMultiplePdfs": "Tek veya birden fazla PDF dosyası desteklenir", + "singlePdfFile": "Tek PDF dosyası", + "pdfWithForms": "Form alanları olan PDF dosyası", + "heicImages": "HEIC/HEIF görüntüler", + "jpgImages": "JPG, JPEG, JP2, JPX Görüntüler", + "pdfsOrImages": "PDF'ler veya görüntüler", + "oneOrMoreOdt": "Bir veya daha fazla ODT dosyası", + "singlePdfOnly": "Yalnızca tek PDF dosyası", + "pdfFiles": "PDF dosyaları", + "multiplePdfs": "Birden fazla PDF dosyası", + "pngImages": "PNG Görüntüler", + "pdfFilesOneOrMore": "PDF dosyaları (bir veya daha fazla)", + "oneOrMoreRtf": "Bir veya daha fazla RTF dosyası", + "svgGraphics": "SVG Grafikler", + "tiffImages": "TIFF görüntüler", + "webpImages": "WebP Görüntüler" + } }, "loader": { "processing": "İşleniyor..." @@ -170,7 +197,8 @@ "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." - } + }, + "sectionTitle": "Sıkça Sorulan Sorular" }, "testimonials": { "title": "Kullanıcılarımız", @@ -226,7 +254,8 @@ "error": "Hata", "success": "Başarılı", "file": "Dosya", - "files": "Dosya" + "files": "Dosya", + "close": "Kapat" }, "about": { "hero": { @@ -319,5 +348,18 @@ "errorRendering": "Sayfa küçük resimleri oluşturulamadı", "error": "Hata", "failedToLoad": "Yüklenemedi" + }, + "howItWorks": { + "title": "Nasıl Çalışır", + "step1": "Dosyanızı tıklayın veya sürükleyin", + "step2": "İşlem düğmesine tıklayın", + "step3": "İşlenmiş dosyanızı anında kaydedin" + }, + "relatedTools": { + "title": "İlgili PDF Araçları" + }, + "simpleMode": { + "title": "PDF Araçları", + "subtitle": "Başlamak için bir araç seçin" } } diff --git a/public/locales/tr/tools.json b/public/locales/tr/tools.json index 7f897ad..a1f2eee 100644 --- a/public/locales/tr/tools.json +++ b/public/locales/tr/tools.json @@ -22,7 +22,29 @@ }, "compressPdf": { "name": "PDF Sıkıştır", - "subtitle": "PDF dosya boyutunu küçültün." + "subtitle": "PDF dosya boyutunu küçültün.", + "algorithmLabel": "Sıkıştırma Algoritması", + "condense": "Condense (Önerilen)", + "photon": "Photon (Fotoğraf Ağırlıklı PDF'ler İçin)", + "condenseInfo": "Condense gelişmiş sıkıştırma kullanır: gereksiz verileri kaldırır, görselleri optimize eder, fontları küçültür. Çoğu PDF için idealdir.", + "photonInfo": "Photon sayfaları görsellere dönüştürür. Fotoğraf ağırlıklı/taranmış PDF'ler için kullanın.", + "photonWarning": "Uyarı: Metin seçilemez hale gelecek ve bağlantılar çalışmayacak.", + "levelLabel": "Sıkıştırma Seviyesi", + "light": "Hafif (Kaliteyi Koru)", + "balanced": "Dengeli (Önerilen)", + "aggressive": "Agresif (Daha Küçük Dosyalar)", + "extreme": "Aşırı (Maksimum Sıkıştırma)", + "grayscale": "Gri Tonlamaya Dönüştür", + "grayscaleHint": "Renk bilgisini kaldırarak dosya boyutunu küçültür", + "customSettings": "Özel Ayarlar", + "customSettingsHint": "Sıkıştırma parametrelerini ince ayarlayın:", + "outputQuality": "Çıktı Kalitesi", + "resizeImagesTo": "Görselleri Şu Boyuta Dönüştür", + "onlyProcessAbove": "Yalnızca Şunun Üzerini İşle", + "removeMetadata": "Meta verileri kaldır", + "subsetFonts": "Font alt kümesi oluştur (kullanılmayan glifleri kaldır)", + "removeThumbnails": "Gömülü küçük resimleri kaldır", + "compressButton": "PDF'yi Sıkıştır" }, "pdfEditor": { "name": "PDF Düzenleyici", @@ -64,9 +86,14 @@ "name": "Sayfa Numaraları", "subtitle": "Belgenize sayfa numaraları ekleyin." }, + "batesNumbering": { + "name": "Bates Numaralandırma", + "subtitle": "Bir veya daha fazla PDF dosyasına sıralı Bates numaraları ekleyin." + }, "addWatermark": { "name": "Filigran Ekle", - "subtitle": "PDF sayfalarınızın üzerine metin veya görsel damgası ekleyin." + "subtitle": "PDF sayfalarınızın üzerine metin veya görsel damgası ekleyin.", + "applyToAllPages": "Tüm sayfalara uygula" }, "headerFooter": { "name": "Üst Bilgi & Alt Bilgi", @@ -76,6 +103,37 @@ "name": "Renkleri Ters Çevir", "subtitle": "PDF'niz için \"karanlık mod\" sürümü oluşturun." }, + "scannerEffect": { + "name": "Tarayıcı Efekti", + "subtitle": "PDF'nizi taranmış bir belge gibi gösterin.", + "scanSettings": "Tarama Ayarları", + "colorspace": "Renk Alanı", + "gray": "Gri", + "border": "Kenarlık", + "rotate": "Döndür", + "rotateVariance": "Döndürme Varyansı", + "brightness": "Parlaklık", + "contrast": "Kontrast", + "blur": "Bulanıklık", + "noise": "Gürültü", + "yellowish": "Sarımsı", + "resolution": "Çözünürlük", + "processButton": "Tarayıcı Efekti Uygula" + }, + "adjustColors": { + "name": "Renkleri Ayarla", + "subtitle": "PDF'inizde parlaklık, kontrast, doygunluk ve daha fazlasını ayarlayın.", + "colorSettings": "Renk Ayarları", + "brightness": "Parlaklık", + "contrast": "Kontrast", + "saturation": "Doygunluk", + "hueShift": "Ton Kaydırma", + "temperature": "Sıcaklık", + "tint": "Renk Tonu", + "gamma": "Gamma", + "sepia": "Sepya", + "processButton": "Renk Ayarlarını Uygula" + }, "backgroundColor": { "name": "Arka Plan Rengi", "subtitle": "PDF'nizin arka plan rengini değiştirin." @@ -105,7 +163,8 @@ }, "removeBlankPages": { "name": "Boş Sayfaları Kaldır", - "subtitle": "Boş sayfaları otomatik olarak tespit edin ve silin." + "subtitle": "Boş sayfaları otomatik olarak tespit edin ve silin.", + "sensitivityHint": "Yüksek = daha katı, yalnızca tamamen boş sayfalar. Düşük = biraz içerik olan sayfalara izin verir." }, "imageToPdf": { "name": "Görselden PDF'ye", @@ -229,7 +288,47 @@ }, "comparePdfs": { "name": "PDF'leri Karşılaştır", - "subtitle": "İki PDF'yi yan yana karşılaştırın." + "subtitle": "İki PDF'yi yan yana karşılaştırın.", + "firstPdf": "İlk PDF", + "secondPdf": "İkinci PDF", + "clickOrDrop": "Tıklayın veya bırakın", + "page": "Sayfa", + "overlay": "Üst üste", + "sideBySide": "Yan yana", + "flicker": "Titreşim", + "syncScroll": "Kaydırmayı senkronize et", + "export": "Dışa aktar", + "exportAsPdf": "PDF olarak dışa aktar", + "splitView": "Bölünmüş görünüm", + "alternating": "Sırayla", + "leftDocument": "Sol belge", + "rightDocument": "Sağ belge", + "original": "Orijinal", + "modified": "Değiştirilmiş", + "searchChanges": "Değişiklikleri ara...", + "deleted": "Silindi", + "added": "Eklendi", + "prevPage": "Önceki sayfa", + "nextPage": "Sonraki sayfa", + "prevChange": "Önceki değişiklik", + "nextChange": "Sonraki değişiklik", + "uploadTwoPdfs": "Farkları görmek için iki PDF yükleyin.", + "noDifferences": "Bu sayfada fark algılanmadı.", + "noMatchingChanges": "Geçerli filtreyle eşleşen değişiklik yok.", + "pageNotExist": "{{page}} sayfası bu PDF'de yok.", + "noPairedPage": "Bu taraf için eşleştirilmiş sayfa yok.", + "buildingModel": "Sayfa eşleştirme modeli oluşturuluyor...", + "indexingPdf": "PDF {{num}} için {{total}} içinden {{page}}. sayfa dizinleniyor...", + "loadingComparison": "{{total}} içinden {{current}}. karşılaştırma yükleniyor...", + "runningOcr": "{{page}}. sayfada OCR çalıştırılıyor...", + "preparingExport": "PDF dışa aktarma hazırlanıyor...", + "renderingPage": "{{total}} içinden {{current}}. sayfa işleniyor...", + "exportError": "Dışa aktarma hatası", + "exportFailed": "Karşılaştırma PDF'i dışa aktarılamadı.", + "loadingFile": "{{name}} yükleniyor...", + "invalidFile": "Geçersiz dosya", + "invalidFileMsg": "Lütfen geçerli bir PDF dosyası seçin.", + "loadError": "PDF yüklenemedi. Bozuk olabilir veya parola korumalı olabilir." }, "posterizePdf": { "name": "PDF'yi Posta Boyutuna Böl", @@ -507,5 +606,66 @@ "name": "Validate PDF Signature", "pageTitle": "Validate PDF Signature - Verify Digital Signatures | BentoPDF", "subtitle": "Verify digital signatures in your PDF files. Check certificate validity, view signer details, and confirm document integrity. All processing happens in your browser." + }, + "pdfToWord": { + "name": "PDF'den Word'e", + "subtitle": "PDF dosyalarını düzenlenebilir Word belgelerine dönüştürün." + }, + "extractImages": { + "name": "Görüntüleri Çıkar", + "subtitle": "PDF dosyalarınızdaki tüm gömülü görüntüleri çıkarın." + }, + "pdfToMarkdown": { + "name": "PDF'den Markdown'a", + "subtitle": "PDF metin ve tablolarını Markdown formatına dönüştürün." + }, + "preparePdfForAi": { + "name": "PDF'yi Yapay Zeka için Hazırla", + "subtitle": "RAG/LLM iş hatları için PDF içeriğini LlamaIndex JSON olarak çıkarın." + }, + "pdfOcg": { + "name": "PDF Katmanları (OCG)", + "subtitle": "PDF'nizdeki OCG katmanlarını görüntüleyin, değiştirin, ekleyin ve silin." + }, + "pdfToPdfa": { + "name": "PDF'den PDF/A'ya", + "subtitle": "Uzun süreli arşivleme için PDF'yi PDF/A'ya dönüştürün." + }, + "rasterizePdf": { + "name": "PDF'yi Rasterleştir", + "subtitle": "PDF'yi görüntü tabanlı PDF'ye dönüştürün. Katmanları düzleştirin ve seçilebilir metni kaldırın." + }, + "pdfWorkflow": { + "name": "PDF İş Akışı Oluşturucu", + "subtitle": "Görsel düğüm düzenleyicisi ile özel PDF işleme hatları oluşturun.", + "nodes": "Düğümler", + "searchNodes": "Düğüm ara...", + "run": "Çalıştır", + "clear": "Temizle", + "save": "Kaydet", + "load": "Yükle", + "export": "Dışa Aktar", + "import": "İçe Aktar", + "ready": "Hazır", + "settings": "Ayarlar", + "processing": "İşleniyor...", + "saveTemplate": "Şablonu Kaydet", + "templateName": "Şablon Adı", + "templatePlaceholder": "örn. Fatura İş Akışı", + "cancel": "İptal", + "loadTemplate": "Şablon Yükle", + "noTemplates": "Henüz kaydedilmiş şablon yok.", + "ok": "Tamam", + "workflowCompleted": "İş akışı tamamlandı", + "errorDuringExecution": "Yürütme sırasında hata oluştu", + "addNodeError": "İş akışını çalıştırmak için en az bir düğüm ekleyin.", + "needInputOutput": "İş akışınızın çalışması için en az bir giriş düğümü ve bir çıkış düğümü gereklidir.", + "enterName": "Lütfen bir ad girin.", + "templateExists": "Bu adla bir şablon zaten mevcut.", + "templateSaved": "\"{{name}}\" şablonu kaydedildi.", + "templateLoaded": "\"{{name}}\" şablonu yüklendi.", + "failedLoadTemplate": "Şablon yüklenemedi.", + "noSettings": "Bu düğüm için yapılandırılabilir ayar yok.", + "advancedSettings": "Gelişmiş Ayarlar" } } diff --git a/public/locales/vi/common.json b/public/locales/vi/common.json index 1ebaa40..0a57f66 100644 --- a/public/locales/vi/common.json +++ b/public/locales/vi/common.json @@ -66,7 +66,34 @@ "pdfOrImages": "PDF hoặc Hình ảnh", "filesNeverLeave": "Tệp của bạn không bao giờ rời khỏi thiết bị.", "addMore": "Thêm tệp", - "clearAll": "Xóa tất cả" + "clearAll": "Xóa tất cả", + "clearFiles": "Xóa tệp", + "hints": { + "singlePdf": "Một tệp PDF duy nhất", + "pdfFile": "Tệp PDF", + "multiplePdfs2": "Nhiều tệp PDF (ít nhất 2)", + "bmpImages": "Ảnh BMP", + "oneOrMorePdfs": "Một hoặc nhiều tệp PDF", + "pdfDocuments": "Tài liệu PDF", + "oneOrMoreCsv": "Một hoặc nhiều tệp CSV", + "multiplePdfsSupported": "Hỗ trợ nhiều tệp PDF", + "singleOrMultiplePdfs": "Hỗ trợ một hoặc nhiều tệp PDF", + "singlePdfFile": "Một tệp PDF", + "pdfWithForms": "Tệp PDF có trường biểu mẫu", + "heicImages": "Ảnh HEIC/HEIF", + "jpgImages": "Ảnh JPG, JPEG, JP2, JPX", + "pdfsOrImages": "PDF hoặc hình ảnh", + "oneOrMoreOdt": "Một hoặc nhiều tệp ODT", + "singlePdfOnly": "Chỉ một tệp PDF duy nhất", + "pdfFiles": "Tệp PDF", + "multiplePdfs": "Nhiều tệp PDF", + "pngImages": "Ảnh PNG", + "pdfFilesOneOrMore": "Tệp PDF (một hoặc nhiều)", + "oneOrMoreRtf": "Một hoặc nhiều tệp RTF", + "svgGraphics": "Đồ họa SVG", + "tiffImages": "Ảnh TIFF", + "webpImages": "Ảnh WebP" + } }, "loader": { "processing": "Đang xử lý..." @@ -170,7 +197,8 @@ "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." - } + }, + "sectionTitle": "Câu hỏi thường gặp" }, "testimonials": { "title": "Người dùng", @@ -226,7 +254,8 @@ "error": "Lỗi", "success": "Thành công", "file": "Tệp", - "files": "Tệp" + "files": "Tệp", + "close": "Đóng" }, "about": { "hero": { @@ -319,5 +348,18 @@ "errorRendering": "Không thể kết xuất hình thu nhỏ trang", "error": "Lỗi", "failedToLoad": "Không thể tải" + }, + "howItWorks": { + "title": "Cách thức hoạt động", + "step1": "Nhấp hoặc kéo tệp của bạn vào đây", + "step2": "Nhấp vào nút xử lý để bắt đầu", + "step3": "Lưu tệp đã xử lý ngay lập tức" + }, + "relatedTools": { + "title": "Công cụ PDF liên quan" + }, + "simpleMode": { + "title": "Công cụ PDF", + "subtitle": "Chọn một công cụ để bắt đầu" } } diff --git a/public/locales/vi/tools.json b/public/locales/vi/tools.json index 04b37af..088bbc5 100644 --- a/public/locales/vi/tools.json +++ b/public/locales/vi/tools.json @@ -86,9 +86,14 @@ "name": "Số trang", "subtitle": "Chèn số trang vào tài liệu của bạn." }, + "batesNumbering": { + "name": "Đánh số Bates", + "subtitle": "Thêm số Bates tuần tự trên một hoặc nhiều tệp PDF." + }, "addWatermark": { "name": "Thêm Watermark", - "subtitle": "Đóng dấu văn bản hoặc hình ảnh lên các trang PDF của bạn." + "subtitle": "Đóng dấu văn bản hoặc hình ảnh lên các trang PDF của bạn.", + "applyToAllPages": "Áp dụng cho tất cả các trang" }, "headerFooter": { "name": "Đầu trang & Chân trang", @@ -98,6 +103,37 @@ "name": "Đảo ngược màu", "subtitle": "Tạo phiên bản \"chế độ tối\" cho PDF của bạn." }, + "scannerEffect": { + "name": "Hiệu ứng máy quét", + "subtitle": "Làm cho PDF trông giống như tài liệu đã quét.", + "scanSettings": "Cài đặt quét", + "colorspace": "Không gian màu", + "gray": "Thang xám", + "border": "Viền", + "rotate": "Xoay", + "rotateVariance": "Biên độ xoay", + "brightness": "Độ sáng", + "contrast": "Độ tương phản", + "blur": "Làm mờ", + "noise": "Nhiễu", + "yellowish": "Ngả vàng", + "resolution": "Độ phân giải", + "processButton": "Áp dụng hiệu ứng máy quét" + }, + "adjustColors": { + "name": "Điều chỉnh màu sắc", + "subtitle": "Tinh chỉnh độ sáng, tương phản, độ bão hòa và nhiều hơn nữa.", + "colorSettings": "Cài đặt màu sắc", + "brightness": "Độ sáng", + "contrast": "Tương phản", + "saturation": "Độ bão hòa", + "hueShift": "Dịch chuyển sắc độ", + "temperature": "Nhiệt độ màu", + "tint": "Sắc thái", + "gamma": "Gamma", + "sepia": "Sepia", + "processButton": "Áp dụng điều chỉnh màu" + }, "backgroundColor": { "name": "Màu nền", "subtitle": "Thay đổi màu nền của PDF của bạn." @@ -127,7 +163,8 @@ }, "removeBlankPages": { "name": "Xóa trang trống", - "subtitle": "Tự động phát hiện và xóa trang trống." + "subtitle": "Tự động phát hiện và xóa trang trống.", + "sensitivityHint": "Cao hơn = nghiêm ngặt hơn, chỉ các trang hoàn toàn trống. Thấp hơn = cho phép trang có một số nội dung." }, "imageToPdf": { "name": "Hình ảnh sang PDF", @@ -255,7 +292,47 @@ }, "comparePdfs": { "name": "So sánh PDF", - "subtitle": "So sánh hai PDF cạnh nhau." + "subtitle": "So sánh hai PDF cạnh nhau.", + "firstPdf": "PDF thứ nhất", + "secondPdf": "PDF thứ hai", + "clickOrDrop": "Nhấp hoặc thả", + "page": "Trang", + "overlay": "Chồng lớp", + "sideBySide": "Cạnh nhau", + "flicker": "Nhấp nháy", + "syncScroll": "Đồng bộ cuộn", + "export": "Xuất", + "exportAsPdf": "Xuất dưới dạng PDF", + "splitView": "Chế độ chia đôi", + "alternating": "Luân phiên", + "leftDocument": "Tài liệu bên trái", + "rightDocument": "Tài liệu bên phải", + "original": "Bản gốc", + "modified": "Đã sửa đổi", + "searchChanges": "Tìm kiếm thay đổi...", + "deleted": "Đã xóa", + "added": "Đã thêm", + "prevPage": "Trang trước", + "nextPage": "Trang sau", + "prevChange": "Thay đổi trước", + "nextChange": "Thay đổi sau", + "uploadTwoPdfs": "Tải lên hai PDF để xem sự khác biệt.", + "noDifferences": "Không phát hiện khác biệt trên trang này.", + "noMatchingChanges": "Không có thay đổi nào khớp với bộ lọc hiện tại.", + "pageNotExist": "Trang {{page}} không tồn tại trong PDF này.", + "noPairedPage": "Không có trang ghép cho phía này.", + "buildingModel": "Đang xây dựng mô hình ghép trang...", + "indexingPdf": "Đang lập chỉ mục PDF {{num}}, trang {{page}} trên {{total}}...", + "loadingComparison": "Đang tải so sánh {{current}} trên {{total}}...", + "runningOcr": "Đang chạy OCR trên trang {{page}}...", + "preparingExport": "Đang chuẩn bị xuất PDF...", + "renderingPage": "Đang kết xuất trang {{current}} trên {{total}}...", + "exportError": "Lỗi xuất", + "exportFailed": "Không thể xuất PDF so sánh.", + "loadingFile": "Đang tải {{name}}...", + "invalidFile": "Tệp không hợp lệ", + "invalidFileMsg": "Vui lòng chọn tệp PDF hợp lệ.", + "loadError": "Không thể tải PDF. Có thể tệp bị hỏng hoặc được bảo vệ bằng mật khẩu." }, "posterizePdf": { "name": "Posterize PDF", @@ -529,5 +606,66 @@ "deskewPdf": { "name": "Chỉnh nghiêng PDF", "subtitle": "Tự động làm thẳng các trang quét bị nghiêng bằng OpenCV." + }, + "pdfToWord": { + "name": "PDF sang Word", + "subtitle": "Chuyển đổi tệp PDF thành tài liệu Word có thể chỉnh sửa." + }, + "extractImages": { + "name": "Trích xuất hình ảnh", + "subtitle": "Trích xuất tất cả hình ảnh nhúng từ tệp PDF của bạn." + }, + "pdfToMarkdown": { + "name": "PDF sang Markdown", + "subtitle": "Chuyển đổi văn bản và bảng PDF sang định dạng Markdown." + }, + "preparePdfForAi": { + "name": "Chuẩn bị PDF cho AI", + "subtitle": "Trích xuất nội dung PDF dưới dạng JSON LlamaIndex cho quy trình RAG/LLM." + }, + "pdfOcg": { + "name": "Lớp PDF (OCG)", + "subtitle": "Xem, chuyển đổi, thêm và xóa các lớp OCG trong PDF của bạn." + }, + "pdfToPdfa": { + "name": "PDF sang PDF/A", + "subtitle": "Chuyển đổi PDF sang PDF/A để lưu trữ lâu dài." + }, + "rasterizePdf": { + "name": "Rasterize PDF", + "subtitle": "Chuyển đổi PDF thành PDF dựa trên hình ảnh. Làm phẳng các lớp và xóa văn bản có thể chọn." + }, + "pdfWorkflow": { + "name": "Trình xây dựng quy trình PDF", + "subtitle": "Xây dựng quy trình xử lý PDF tùy chỉnh bằng trình chỉnh sửa nút trực quan.", + "nodes": "Nút", + "searchNodes": "Tìm kiếm nút...", + "run": "Chạy", + "clear": "Xóa", + "save": "Lưu", + "load": "Tải", + "export": "Xuất", + "import": "Nhập", + "ready": "Sẵn sàng", + "settings": "Cài đặt", + "processing": "Đang xử lý...", + "saveTemplate": "Lưu mẫu", + "templateName": "Tên mẫu", + "templatePlaceholder": "ví dụ: Quy trình hóa đơn", + "cancel": "Hủy", + "loadTemplate": "Tải mẫu", + "noTemplates": "Chưa có mẫu nào được lưu.", + "ok": "OK", + "workflowCompleted": "Quy trình đã hoàn tất", + "errorDuringExecution": "Lỗi trong quá trình thực thi", + "addNodeError": "Thêm ít nhất một nút để chạy quy trình.", + "needInputOutput": "Quy trình của bạn cần ít nhất một nút đầu vào và một nút đầu ra để chạy.", + "enterName": "Vui lòng nhập tên.", + "templateExists": "Đã tồn tại mẫu có tên này.", + "templateSaved": "Đã lưu mẫu \"{{name}}\".", + "templateLoaded": "Đã tải mẫu \"{{name}}\".", + "failedLoadTemplate": "Không thể tải mẫu.", + "noSettings": "Không có cài đặt nào có thể cấu hình cho nút này.", + "advancedSettings": "Cài đặt nâng cao" } } diff --git a/public/locales/zh-TW/common.json b/public/locales/zh-TW/common.json index 777dde8..7b10dc4 100644 --- a/public/locales/zh-TW/common.json +++ b/public/locales/zh-TW/common.json @@ -66,7 +66,34 @@ "pdfOrImages": "PDF 或圖片", "filesNeverLeave": "你的檔案永遠不會離開你的裝置。", "addMore": "添加更多檔案", - "clearAll": "清除全部" + "clearAll": "清除全部", + "clearFiles": "清除檔案", + "hints": { + "singlePdf": "單個 PDF 檔案", + "pdfFile": "PDF 檔案", + "multiplePdfs2": "多個 PDF 檔案(至少 2 個)", + "bmpImages": "BMP 圖片", + "oneOrMorePdfs": "一個或多個 PDF 檔案", + "pdfDocuments": "PDF 文件", + "oneOrMoreCsv": "一個或多個 CSV 檔案", + "multiplePdfsSupported": "支援多個 PDF 檔案", + "singleOrMultiplePdfs": "支援單個或多個 PDF 檔案", + "singlePdfFile": "單個 PDF 檔案", + "pdfWithForms": "含有表單欄位的 PDF 檔案", + "heicImages": "HEIC/HEIF 圖片", + "jpgImages": "JPG、JPEG、JP2、JPX 圖片", + "pdfsOrImages": "PDF 或圖片", + "oneOrMoreOdt": "一個或多個 ODT 檔案", + "singlePdfOnly": "僅限單個 PDF 檔案", + "pdfFiles": "PDF 檔案", + "multiplePdfs": "多個 PDF 檔案", + "pngImages": "PNG 圖片", + "pdfFilesOneOrMore": "PDF 檔案(一個或多個)", + "oneOrMoreRtf": "一個或多個 RTF 檔案", + "svgGraphics": "SVG 圖形", + "tiffImages": "TIFF 圖片", + "webpImages": "WebP 圖片" + } }, "loader": { "processing": "正在處理..." @@ -170,7 +197,8 @@ "analytics": { "question": "你會使用 Cookies 或網站分析來追蹤我嗎?", "answer": "我們在乎你的隱私。BentoPDF 並不追蹤個人資訊。我們僅使用 Simple Analytics 來查看匿名訪問次數。這代表我們能知道有多少使用者造訪過我們的網站,但我們永遠都不會知道你是誰。Simple Analytics 完全符合 GDPR 規範且尊重你的隱私。" - } + }, + "sectionTitle": "常見問題" }, "testimonials": { "title": "看看我們的", @@ -226,7 +254,8 @@ "error": "錯誤", "success": "成功", "file": "檔案", - "files": "檔案" + "files": "檔案", + "close": "關閉" }, "about": { "hero": { @@ -319,5 +348,18 @@ "errorRendering": "無法渲染頁面縮圖", "error": "錯誤", "failedToLoad": "載入失敗" + }, + "howItWorks": { + "title": "使用方式", + "step1": "點擊或拖放您的檔案到此處", + "step2": "點擊處理按鈕開始", + "step3": "立即儲存處理後的檔案" + }, + "relatedTools": { + "title": "相關 PDF 工具" + }, + "simpleMode": { + "title": "PDF 工具", + "subtitle": "選擇一個工具開始使用" } } diff --git a/public/locales/zh-TW/tools.json b/public/locales/zh-TW/tools.json index 6ef2fe0..1cfb7e6 100644 --- a/public/locales/zh-TW/tools.json +++ b/public/locales/zh-TW/tools.json @@ -22,7 +22,29 @@ }, "compressPdf": { "name": "壓縮 PDF", - "subtitle": "降低你的 PDF 檔案大小。" + "subtitle": "降低你的 PDF 檔案大小。", + "algorithmLabel": "壓縮演算法", + "condense": "Condense(推薦)", + "photon": "Photon(適用於圖片較多的 PDF)", + "condenseInfo": "Condense 使用高級壓縮:移除冗餘資料、優化圖片、精簡字型。適用於大多數 PDF。", + "photonInfo": "Photon 將頁面轉換為圖片。適用於圖片較多/掃描的 PDF。", + "photonWarning": "警告:文字將無法選取,連結將失效。", + "levelLabel": "壓縮等級", + "light": "輕度(保持品質)", + "balanced": "平衡(推薦)", + "aggressive": "積極(更小檔案)", + "extreme": "極限(最大壓縮)", + "grayscale": "轉換為灰階", + "grayscaleHint": "透過移除色彩資訊來縮小檔案大小", + "customSettings": "自訂設定", + "customSettingsHint": "微調壓縮參數:", + "outputQuality": "輸出品質", + "resizeImagesTo": "調整圖片至", + "onlyProcessAbove": "僅處理高於", + "removeMetadata": "移除中繼資料", + "subsetFonts": "精簡字型(移除未使用的字符)", + "removeThumbnails": "移除嵌入的縮圖", + "compressButton": "壓縮 PDF" }, "pdfEditor": { "name": "PDF 編輯器", @@ -64,9 +86,14 @@ "name": "頁碼", "subtitle": "在你的文件中插入頁碼。" }, + "batesNumbering": { + "name": "Bates編號", + "subtitle": "在一個或多個PDF檔案中新增連續的Bates編號。" + }, "addWatermark": { "name": "添加浮水印", - "subtitle": "在你的 PDF 頁面上壓印文字或圖片。" + "subtitle": "在你的 PDF 頁面上壓印文字或圖片。", + "applyToAllPages": "套用至所有頁面" }, "headerFooter": { "name": "頁首與頁尾", @@ -76,6 +103,37 @@ "name": "反轉顏色", "subtitle": "為你的 PDF 建立深色版本。" }, + "scannerEffect": { + "name": "掃描效果", + "subtitle": "讓你的 PDF 看起來像掃描文件。", + "scanSettings": "掃描設定", + "colorspace": "色彩空間", + "gray": "灰階", + "border": "邊框", + "rotate": "旋轉", + "rotateVariance": "旋轉變異", + "brightness": "亮度", + "contrast": "對比度", + "blur": "模糊", + "noise": "雜訊", + "yellowish": "泛黃", + "resolution": "解析度", + "processButton": "套用掃描效果" + }, + "adjustColors": { + "name": "調整顏色", + "subtitle": "微調 PDF 的亮度、對比度、飽和度等。", + "colorSettings": "顏色設定", + "brightness": "亮度", + "contrast": "對比度", + "saturation": "飽和度", + "hueShift": "色相偏移", + "temperature": "色溫", + "tint": "色調", + "gamma": "Gamma", + "sepia": "復古色", + "processButton": "套用顏色調整" + }, "backgroundColor": { "name": "背景顏色", "subtitle": "更改你的 PDF 的背景顏色。" @@ -105,7 +163,8 @@ }, "removeBlankPages": { "name": "移除空白頁面", - "subtitle": "自動偵測並刪除空白頁面。" + "subtitle": "自動偵測並刪除空白頁面。", + "sensitivityHint": "越高 = 越嚴格,僅偵測純空白頁面。越低 = 允許包含少量內容的頁面。" }, "imageToPdf": { "name": "圖片轉 PDF", @@ -229,7 +288,47 @@ }, "comparePdfs": { "name": "比較 PDF", - "subtitle": "並排比較兩個 PDF。" + "subtitle": "並排比較兩個 PDF。", + "firstPdf": "第一個 PDF", + "secondPdf": "第二個 PDF", + "clickOrDrop": "點擊或拖放", + "page": "頁面", + "overlay": "疊加", + "sideBySide": "並排", + "flicker": "閃爍", + "syncScroll": "同步捲動", + "export": "匯出", + "exportAsPdf": "匯出為 PDF", + "splitView": "分割檢視", + "alternating": "交替", + "leftDocument": "左側文件", + "rightDocument": "右側文件", + "original": "原始", + "modified": "修改後", + "searchChanges": "搜尋變更...", + "deleted": "已刪除", + "added": "已新增", + "prevPage": "上一頁", + "nextPage": "下一頁", + "prevChange": "上一個變更", + "nextChange": "下一個變更", + "uploadTwoPdfs": "上傳兩個 PDF 以查看差異。", + "noDifferences": "此頁面未偵測到差異。", + "noMatchingChanges": "沒有符合目前篩選條件的變更。", + "pageNotExist": "此 PDF 中不存在第 {{page}} 頁。", + "noPairedPage": "此側沒有配對頁面。", + "buildingModel": "正在建立頁面配對模型...", + "indexingPdf": "正在索引 PDF {{num}},第 {{page}} / {{total}} 頁...", + "loadingComparison": "正在載入比較 {{current}} / {{total}}...", + "runningOcr": "正在對第 {{page}} 頁執行 OCR...", + "preparingExport": "正在準備 PDF 匯出...", + "renderingPage": "正在轉譯第 {{current}} / {{total}} 頁...", + "exportError": "匯出錯誤", + "exportFailed": "無法匯出比較 PDF。", + "loadingFile": "正在載入 {{name}}...", + "invalidFile": "無效檔案", + "invalidFileMsg": "請選擇有效的 PDF 檔案。", + "loadError": "無法載入 PDF。檔案可能已損毀或受密碼保護。" }, "posterizePdf": { "name": "海報化 PDF", @@ -507,5 +606,66 @@ "name": "Validate PDF Signature", "pageTitle": "Validate PDF Signature - Verify Digital Signatures | BentoPDF", "subtitle": "Verify digital signatures in your PDF files. Check certificate validity, view signer details, and confirm document integrity. All processing happens in your browser." + }, + "pdfToWord": { + "name": "PDF 轉 Word", + "subtitle": "將 PDF 檔案轉換為可編輯的 Word 文件。" + }, + "extractImages": { + "name": "擷取圖片", + "subtitle": "從 PDF 檔案中擷取所有嵌入的圖片。" + }, + "pdfToMarkdown": { + "name": "PDF 轉 Markdown", + "subtitle": "將 PDF 文字和表格轉換為 Markdown 格式。" + }, + "preparePdfForAi": { + "name": "為 AI 準備 PDF", + "subtitle": "將 PDF 內容擷取為 LlamaIndex JSON,用於 RAG/LLM 管線。" + }, + "pdfOcg": { + "name": "PDF 圖層 (OCG)", + "subtitle": "檢視、切換、新增和刪除 PDF 中的 OCG 圖層。" + }, + "pdfToPdfa": { + "name": "PDF 轉 PDF/A", + "subtitle": "將 PDF 轉換為 PDF/A 格式以進行長期歸檔。" + }, + "rasterizePdf": { + "name": "柵格化 PDF", + "subtitle": "將 PDF 轉換為基於影像的 PDF。展平圖層並移除可選取的文字。" + }, + "pdfWorkflow": { + "name": "PDF 工作流程建構器", + "subtitle": "使用視覺化節點編輯器建構自訂 PDF 處理管線。", + "nodes": "節點", + "searchNodes": "搜尋節點...", + "run": "執行", + "clear": "清除", + "save": "儲存", + "load": "載入", + "export": "匯出", + "import": "匯入", + "ready": "就緒", + "settings": "設定", + "processing": "處理中...", + "saveTemplate": "儲存範本", + "templateName": "範本名稱", + "templatePlaceholder": "例如:發票處理工作流程", + "cancel": "取消", + "loadTemplate": "載入範本", + "noTemplates": "目前沒有已儲存的範本。", + "ok": "確定", + "workflowCompleted": "工作流程已完成", + "errorDuringExecution": "執行過程中發生錯誤", + "addNodeError": "請至少新增一個節點以執行工作流程。", + "needInputOutput": "您的工作流程至少需要一個輸入節點和一個輸出節點才能執行。", + "enterName": "請輸入名稱。", + "templateExists": "已存在相同名稱的範本。", + "templateSaved": "範本「{{name}}」已儲存。", + "templateLoaded": "範本「{{name}}」已載入。", + "failedLoadTemplate": "載入範本失敗。", + "noSettings": "此節點沒有可設定的選項。", + "advancedSettings": "進階設定" } } diff --git a/public/locales/zh/common.json b/public/locales/zh/common.json index 9fe300b..a3b76bd 100644 --- a/public/locales/zh/common.json +++ b/public/locales/zh/common.json @@ -66,7 +66,34 @@ "pdfOrImages": "PDF 或图片", "filesNeverLeave": "您的文件从未离开您的设备。", "addMore": "添加更多文件", - "clearAll": "清空所有" + "clearAll": "清空所有", + "clearFiles": "清除文件", + "hints": { + "singlePdf": "单个 PDF 文件", + "pdfFile": "PDF 文件", + "multiplePdfs2": "多个 PDF 文件(至少 2 个)", + "bmpImages": "BMP 图片", + "oneOrMorePdfs": "一个或多个 PDF 文件", + "pdfDocuments": "PDF 文档", + "oneOrMoreCsv": "一个或多个 CSV 文件", + "multiplePdfsSupported": "支持多个 PDF 文件", + "singleOrMultiplePdfs": "支持单个或多个 PDF 文件", + "singlePdfFile": "单个 PDF 文件", + "pdfWithForms": "带有表单字段的 PDF 文件", + "heicImages": "HEIC/HEIF 图片", + "jpgImages": "JPG、JPEG、JP2、JPX 图片", + "pdfsOrImages": "PDF 或图片", + "oneOrMoreOdt": "一个或多个 ODT 文件", + "singlePdfOnly": "仅限单个 PDF 文件", + "pdfFiles": "PDF 文件", + "multiplePdfs": "多个 PDF 文件", + "pngImages": "PNG 图片", + "pdfFilesOneOrMore": "PDF 文件(一个或多个)", + "oneOrMoreRtf": "一个或多个 RTF 文件", + "svgGraphics": "SVG 图形", + "tiffImages": "TIFF 图片", + "webpImages": "WebP 图片" + } }, "loader": { "processing": "处理中..." @@ -170,7 +197,8 @@ "analytics": { "question": "你们使用 Cookie 或分析来跟踪我吗?", "answer": "我们关心您的隐私。BentoPDF 不会跟踪个人信息。我们仅使用 Simple Analytics 查看匿名访问计数。这意味着我们可以知道有多少用户访问我们的网站,但我们永远不知道您是谁。Simple Analytics 完全符合 GDPR 并尊重您的隐私。" - } + }, + "sectionTitle": "常见问题" }, "testimonials": { "title": "我们的", @@ -226,7 +254,8 @@ "error": "错误", "success": "成功", "file": "文件", - "files": "文件" + "files": "文件", + "close": "关闭" }, "about": { "hero": { @@ -319,5 +348,18 @@ "errorRendering": "渲染页面缩略图失败", "error": "错误", "failedToLoad": "加载失败" + }, + "howItWorks": { + "title": "使用方法", + "step1": "点击或拖放您的文件到此处", + "step2": "点击处理按钮开始", + "step3": "立即保存处理后的文件" + }, + "relatedTools": { + "title": "相关 PDF 工具" + }, + "simpleMode": { + "title": "PDF 工具", + "subtitle": "选择一个工具开始使用" } } diff --git a/public/locales/zh/tools.json b/public/locales/zh/tools.json index a93c822..adefe6a 100644 --- a/public/locales/zh/tools.json +++ b/public/locales/zh/tools.json @@ -86,9 +86,14 @@ "name": "页码", "subtitle": "将页码插入到您的文档中。" }, + "batesNumbering": { + "name": "Bates编号", + "subtitle": "在一个或多个PDF文件中添加连续的Bates编号。" + }, "addWatermark": { "name": "添加水印", - "subtitle": "在您的 PDF 页面上添加文字或图片水印。" + "subtitle": "在您的 PDF 页面上添加文字或图片水印。", + "applyToAllPages": "应用到所有页面" }, "headerFooter": { "name": "页眉和页脚", @@ -98,6 +103,37 @@ "name": "反转颜色", "subtitle": "创建您的 PDF 的“暗黑模式”版本。" }, + "scannerEffect": { + "name": "扫描效果", + "subtitle": "让您的 PDF 看起来像扫描文件。", + "scanSettings": "扫描设置", + "colorspace": "色彩空间", + "gray": "灰度", + "border": "边框", + "rotate": "旋转", + "rotateVariance": "旋转偏差", + "brightness": "亮度", + "contrast": "对比度", + "blur": "模糊", + "noise": "噪点", + "yellowish": "泛黄", + "resolution": "分辨率", + "processButton": "应用扫描效果" + }, + "adjustColors": { + "name": "调整颜色", + "subtitle": "微调 PDF 的亮度、对比度、饱和度等。", + "colorSettings": "颜色设置", + "brightness": "亮度", + "contrast": "对比度", + "saturation": "饱和度", + "hueShift": "色相偏移", + "temperature": "色温", + "tint": "色调", + "gamma": "Gamma", + "sepia": "复古色", + "processButton": "应用颜色调整" + }, "backgroundColor": { "name": "背景颜色", "subtitle": "更改您的 PDF 的背景颜色。" @@ -108,7 +144,10 @@ }, "addStamps": { "name": "添加印章", - "subtitle": "使用注释工具栏向您的 PDF 添加图片印章。" + "subtitle": "使用注释工具栏向您的 PDF 添加图片印章。", + "usernameLabel": "印章用户名", + "usernamePlaceholder": "输入您的姓名(用于印章)", + "usernameHint": "此名称将显示在您创建的印章上。" }, "removeAnnotations": { "name": "移除注释", @@ -124,7 +163,8 @@ }, "removeBlankPages": { "name": "移除空白页", - "subtitle": "自动检测并删除空白页。" + "subtitle": "自动检测并删除空白页。", + "sensitivityHint": "越高 = 越严格,仅检测纯空白页。越低 = 允许包含少量内容的页面。" }, "imageToPdf": { "name": "图片转 PDF", @@ -252,7 +292,47 @@ }, "comparePdfs": { "name": "比较 PDF", - "subtitle": "并排比较两个 PDF。" + "subtitle": "并排比较两个 PDF。", + "firstPdf": "第一个 PDF", + "secondPdf": "第二个 PDF", + "clickOrDrop": "点击或拖放", + "page": "页面", + "overlay": "叠加", + "sideBySide": "并排", + "flicker": "闪烁", + "syncScroll": "同步滚动", + "export": "导出", + "exportAsPdf": "导出为 PDF", + "splitView": "分屏视图", + "alternating": "交替", + "leftDocument": "左侧文档", + "rightDocument": "右侧文档", + "original": "原始", + "modified": "修改后", + "searchChanges": "搜索更改...", + "deleted": "已删除", + "added": "已添加", + "prevPage": "上一页", + "nextPage": "下一页", + "prevChange": "上一处更改", + "nextChange": "下一处更改", + "uploadTwoPdfs": "上传两个 PDF 以查看差异。", + "noDifferences": "此页面未检测到差异。", + "noMatchingChanges": "没有与当前筛选条件匹配的更改。", + "pageNotExist": "此 PDF 中不存在第 {{page}} 页。", + "noPairedPage": "此侧没有配对页面。", + "buildingModel": "正在构建页面配对模型...", + "indexingPdf": "正在索引 PDF {{num}},第 {{page}} / {{total}} 页...", + "loadingComparison": "正在加载比较 {{current}} / {{total}}...", + "runningOcr": "正在对第 {{page}} 页运行 OCR...", + "preparingExport": "正在准备 PDF 导出...", + "renderingPage": "正在渲染第 {{current}} / {{total}} 页...", + "exportError": "导出错误", + "exportFailed": "无法导出比较 PDF。", + "loadingFile": "正在加载 {{name}}...", + "invalidFile": "无效文件", + "invalidFileMsg": "请选择有效的 PDF 文件。", + "loadError": "无法加载 PDF。文件可能已损坏或受密码保护。" }, "posterizePdf": { "name": "海报化 PDF", @@ -526,5 +606,66 @@ "deskewPdf": { "name": "校正 PDF", "subtitle": "使用 OpenCV 自动校正倾斜的扫描页面。" + }, + "pdfToWord": { + "name": "PDF 转 Word", + "subtitle": "将 PDF 文件转换为可编辑的 Word 文档。" + }, + "extractImages": { + "name": "提取图片", + "subtitle": "从 PDF 文件中提取所有嵌入的图片。" + }, + "pdfToMarkdown": { + "name": "PDF 转 Markdown", + "subtitle": "将 PDF 文本和表格转换为 Markdown 格式。" + }, + "preparePdfForAi": { + "name": "为 AI 准备 PDF", + "subtitle": "将 PDF 内容提取为 LlamaIndex JSON,用于 RAG/LLM 管道。" + }, + "pdfOcg": { + "name": "PDF 图层 (OCG)", + "subtitle": "查看、切换、添加和删除 PDF 中的 OCG 图层。" + }, + "pdfToPdfa": { + "name": "PDF 转 PDF/A", + "subtitle": "将 PDF 转换为 PDF/A 格式以进行长期存档。" + }, + "rasterizePdf": { + "name": "栅格化 PDF", + "subtitle": "将 PDF 转换为基于图像的 PDF。展平图层并移除可选择的文本。" + }, + "pdfWorkflow": { + "name": "PDF 工作流构建器", + "subtitle": "使用可视化节点编辑器构建自定义 PDF 处理流水线。", + "nodes": "节点", + "searchNodes": "搜索节点...", + "run": "运行", + "clear": "清除", + "save": "保存", + "load": "加载", + "export": "导出", + "import": "导入", + "ready": "就绪", + "settings": "设置", + "processing": "处理中...", + "saveTemplate": "保存模板", + "templateName": "模板名称", + "templatePlaceholder": "例如:发票处理工作流", + "cancel": "取消", + "loadTemplate": "加载模板", + "noTemplates": "暂无已保存的模板。", + "ok": "确定", + "workflowCompleted": "工作流已完成", + "errorDuringExecution": "执行过程中出错", + "addNodeError": "请至少添加一个节点以运行工作流。", + "needInputOutput": "工作流至少需要一个输入节点和一个输出节点才能运行。", + "enterName": "请输入名称。", + "templateExists": "已存在同名模板。", + "templateSaved": "模板「{{name}}」已保存。", + "templateLoaded": "模板「{{name}}」已加载。", + "failedLoadTemplate": "加载模板失败。", + "noSettings": "此节点没有可配置的设置。", + "advancedSettings": "高级设置" } } diff --git a/public/pdfjs-viewer/viewer.css b/public/pdfjs-viewer/viewer.css index f127b2a..4dcd817 100644 --- a/public/pdfjs-viewer/viewer.css +++ b/public/pdfjs-viewer/viewer.css @@ -13,2431 +13,2651 @@ * limitations under the License. */ -.messageBar{ - --closing-button-icon:url(images/messageBar_closingButton.svg); - --message-bar-close-button-color:var(--text-primary-color); - --message-bar-close-button-color-hover:var(--text-primary-color); - --message-bar-close-button-border-radius:4px; - --message-bar-close-button-border:none; - --csstools-light-dark-toggle--31:var(--csstools-color-scheme--light) rgb(251 251 254 / 0.14); - --message-bar-close-button-hover-bg-color:var(--csstools-light-dark-toggle--31, rgb(21 20 26 / 0.14)); - --csstools-light-dark-toggle--32:var(--csstools-color-scheme--light) rgb(251 251 254 / 0.21); - --message-bar-close-button-active-bg-color:var(--csstools-light-dark-toggle--32, rgb(21 20 26 / 0.21)); - --csstools-light-dark-toggle--33:var(--csstools-color-scheme--light) rgb(251 251 254 / 0.07); - --message-bar-close-button-focus-bg-color:var(--csstools-light-dark-toggle--33, rgb(21 20 26 / 0.07)); -} - -@supports (color: light-dark(red, red)) and (color: rgb(0 0 0 / 0)){ -.messageBar{ - --message-bar-close-button-hover-bg-color:light-dark( - rgb(21 20 26 / 0.14), - rgb(251 251 254 / 0.14) +.messageBar { + --closing-button-icon: url(images/messageBar_closingButton.svg); + --message-bar-close-button-color: var(--text-primary-color); + --message-bar-close-button-color-hover: var(--text-primary-color); + --message-bar-close-button-border-radius: 4px; + --message-bar-close-button-border: none; + --csstools-light-dark-toggle--31: var(--csstools-color-scheme--light) + rgb(251 251 254 / 0.14); + --message-bar-close-button-hover-bg-color: var( + --csstools-light-dark-toggle--31, + rgb(21 20 26 / 0.14) ); - --message-bar-close-button-active-bg-color:light-dark( - rgb(21 20 26 / 0.21), - rgb(251 251 254 / 0.21) + --csstools-light-dark-toggle--32: var(--csstools-color-scheme--light) + rgb(251 251 254 / 0.21); + --message-bar-close-button-active-bg-color: var( + --csstools-light-dark-toggle--32, + rgb(21 20 26 / 0.21) ); - --message-bar-close-button-focus-bg-color:light-dark( - rgb(21 20 26 / 0.07), - rgb(251 251 254 / 0.07) + --csstools-light-dark-toggle--33: var(--csstools-color-scheme--light) + rgb(251 251 254 / 0.07); + --message-bar-close-button-focus-bg-color: var( + --csstools-light-dark-toggle--33, + rgb(21 20 26 / 0.07) ); } -} -@supports not (color: light-dark(tan, tan)){ - -.messageBar *{ - --csstools-light-dark-toggle--31:var(--csstools-color-scheme--light) rgb(251 251 254 / 0.14); - --message-bar-close-button-hover-bg-color:var(--csstools-light-dark-toggle--31, rgb(21 20 26 / 0.14)); - --csstools-light-dark-toggle--32:var(--csstools-color-scheme--light) rgb(251 251 254 / 0.21); - --message-bar-close-button-active-bg-color:var(--csstools-light-dark-toggle--32, rgb(21 20 26 / 0.21)); - --csstools-light-dark-toggle--33:var(--csstools-color-scheme--light) rgb(251 251 254 / 0.07); - --message-bar-close-button-focus-bg-color:var(--csstools-light-dark-toggle--33, rgb(21 20 26 / 0.07)); +@supports (color: light-dark(red, red)) and (color: rgb(0 0 0 / 0)) { + .messageBar { + --message-bar-close-button-hover-bg-color: light-dark( + rgb(21 20 26 / 0.14), + rgb(251 251 254 / 0.14) + ); + --message-bar-close-button-active-bg-color: light-dark( + rgb(21 20 26 / 0.21), + rgb(251 251 254 / 0.21) + ); + --message-bar-close-button-focus-bg-color: light-dark( + rgb(21 20 26 / 0.07), + rgb(251 251 254 / 0.07) + ); } } -@media screen and (forced-colors: active){ - -.messageBar{ - --message-bar-close-button-color:ButtonText; - --message-bar-close-button-border:1px solid ButtonText; - --message-bar-close-button-hover-bg-color:ButtonText; - --message-bar-close-button-active-bg-color:ButtonText; - --message-bar-close-button-focus-bg-color:ButtonText; - --message-bar-close-button-color-hover:HighlightText; -} +@supports not (color: light-dark(tan, tan)) { + .messageBar * { + --csstools-light-dark-toggle--31: var(--csstools-color-scheme--light) + rgb(251 251 254 / 0.14); + --message-bar-close-button-hover-bg-color: var( + --csstools-light-dark-toggle--31, + rgb(21 20 26 / 0.14) + ); + --csstools-light-dark-toggle--32: var(--csstools-color-scheme--light) + rgb(251 251 254 / 0.21); + --message-bar-close-button-active-bg-color: var( + --csstools-light-dark-toggle--32, + rgb(21 20 26 / 0.21) + ); + --csstools-light-dark-toggle--33: var(--csstools-color-scheme--light) + rgb(251 251 254 / 0.07); + --message-bar-close-button-focus-bg-color: var( + --csstools-light-dark-toggle--33, + rgb(21 20 26 / 0.07) + ); } - -.messageBar{ - - display:flex; - position:relative; - padding:8px 8px 8px 16px; - flex-direction:column; - justify-content:center; - align-items:center; - gap:8px; - -webkit-user-select:none; - -moz-user-select:none; - user-select:none; - - border-radius:4px; - - border:1px solid var(--message-bar-border-color); - background:var(--message-bar-bg-color); - color:var(--message-bar-fg-color); } -.messageBar > div{ - display:flex; - align-items:flex-start; - gap:8px; - align-self:stretch; +@media screen and (forced-colors: active) { + .messageBar { + --message-bar-close-button-color: ButtonText; + --message-bar-close-button-border: 1px solid ButtonText; + --message-bar-close-button-hover-bg-color: ButtonText; + --message-bar-close-button-active-bg-color: ButtonText; + --message-bar-close-button-focus-bg-color: ButtonText; + --message-bar-close-button-color-hover: HighlightText; } - -:is(.messageBar > div)::before{ - content:""; - display:inline-block; - width:16px; - height:16px; - -webkit-mask-image:var(--message-bar-icon); - mask-image:var(--message-bar-icon); - -webkit-mask-size:cover; - mask-size:cover; - background-color:var(--message-bar-icon-color); - flex-shrink:0; - } - -.messageBar button{ - cursor:pointer; - } - -:is(.messageBar button):focus-visible{ - outline:var(--focus-ring-outline); - outline-offset:2px; - } - -.messageBar .closeButton{ - width:32px; - height:32px; - background:none; - border-radius:var(--message-bar-close-button-border-radius); - border:var(--message-bar-close-button-border); - - display:flex; - align-items:center; - justify-content:center; - } - -:is(.messageBar .closeButton)::before{ - content:""; - display:inline-block; - width:16px; - height:16px; - -webkit-mask-image:var(--closing-button-icon); - mask-image:var(--closing-button-icon); - -webkit-mask-size:cover; - mask-size:cover; - background-color:var(--message-bar-close-button-color); - } - -:is(.messageBar .closeButton):is(:hover,:active,:focus)::before{ - background-color:var(--message-bar-close-button-color-hover); - } - -:is(.messageBar .closeButton):hover{ - background-color:var(--message-bar-close-button-hover-bg-color); - } - -:is(.messageBar .closeButton):active{ - background-color:var(--message-bar-close-button-active-bg-color); - } - -:is(.messageBar .closeButton):focus{ - background-color:var(--message-bar-close-button-focus-bg-color); - } - -:is(.messageBar .closeButton) > span{ - display:inline-block; - width:0; - height:0; - overflow:hidden; - } - -#editorUndoBar{ - --csstools-light-dark-toggle--34:var(--csstools-color-scheme--light) #fbfbfe; - --text-primary-color:var(--csstools-light-dark-toggle--34, #15141a); - - --message-bar-icon:url(images/messageBar_info.svg); - --csstools-light-dark-toggle--35:var(--csstools-color-scheme--light) #73a7f3; - --message-bar-icon-color:var(--csstools-light-dark-toggle--35, #0060df); - --csstools-light-dark-toggle--36:var(--csstools-color-scheme--light) #003070; - --message-bar-bg-color:var(--csstools-light-dark-toggle--36, #deeafc); - --message-bar-fg-color:var(--text-primary-color); - --csstools-light-dark-toggle--37:var(--csstools-color-scheme--light) rgb(255 255 255 / 0.08); - --message-bar-border-color:var(--csstools-light-dark-toggle--37, rgb(0 0 0 / 0.08)); - - --csstools-light-dark-toggle--38:var(--csstools-color-scheme--light) rgb(255 255 255 / 0.08); - - --undo-button-bg-color:var(--csstools-light-dark-toggle--38, rgb(21 20 26 / 0.07)); - --csstools-light-dark-toggle--39:var(--csstools-color-scheme--light) rgb(255 255 255 / 0.14); - --undo-button-bg-color-hover:var(--csstools-light-dark-toggle--39, rgb(21 20 26 / 0.14)); - --csstools-light-dark-toggle--40:var(--csstools-color-scheme--light) rgb(255 255 255 / 0.21); - --undo-button-bg-color-active:var(--csstools-light-dark-toggle--40, rgb(21 20 26 / 0.21)); - - --csstools-light-dark-toggle--41:var(--csstools-color-scheme--light) #0df; - - --undo-button-border:1px solid var(--csstools-light-dark-toggle--41, #0060df); - - --undo-button-fg-color:var(--message-bar-fg-color); - --undo-button-fg-color-hover:var(--undo-button-fg-color); - --undo-button-fg-color-active:var(--undo-button-fg-color); } -@supports (color: light-dark(red, red)){ -#editorUndoBar{ - --text-primary-color:light-dark(#15141a, #fbfbfe); - --message-bar-icon-color:light-dark(#0060df, #73a7f3); - --message-bar-bg-color:light-dark(#deeafc, #003070); -} +.messageBar { + display: flex; + position: relative; + padding: 8px 8px 8px 16px; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 8px; + -webkit-user-select: none; + -moz-user-select: none; + user-select: none; + + border-radius: 4px; + + border: 1px solid var(--message-bar-border-color); + background: var(--message-bar-bg-color); + color: var(--message-bar-fg-color); } -@supports (color: light-dark(red, red)) and (color: rgb(0 0 0 / 0)){ -#editorUndoBar{ - --message-bar-border-color:light-dark( - rgb(0 0 0 / 0.08), - rgb(255 255 255 / 0.08) +.messageBar > div { + display: flex; + align-items: flex-start; + gap: 8px; + align-self: stretch; +} + +:is(.messageBar > div)::before { + content: ''; + display: inline-block; + width: 16px; + height: 16px; + -webkit-mask-image: var(--message-bar-icon); + mask-image: var(--message-bar-icon); + -webkit-mask-size: cover; + mask-size: cover; + background-color: var(--message-bar-icon-color); + flex-shrink: 0; +} + +.messageBar button { + cursor: pointer; +} + +:is(.messageBar button):focus-visible { + outline: var(--focus-ring-outline); + outline-offset: 2px; +} + +.messageBar .closeButton { + width: 32px; + height: 32px; + background: none; + border-radius: var(--message-bar-close-button-border-radius); + border: var(--message-bar-close-button-border); + + display: flex; + align-items: center; + justify-content: center; +} + +:is(.messageBar .closeButton)::before { + content: ''; + display: inline-block; + width: 16px; + height: 16px; + -webkit-mask-image: var(--closing-button-icon); + mask-image: var(--closing-button-icon); + -webkit-mask-size: cover; + mask-size: cover; + background-color: var(--message-bar-close-button-color); +} + +:is(.messageBar .closeButton):is(:hover, :active, :focus)::before { + background-color: var(--message-bar-close-button-color-hover); +} + +:is(.messageBar .closeButton):hover { + background-color: var(--message-bar-close-button-hover-bg-color); +} + +:is(.messageBar .closeButton):active { + background-color: var(--message-bar-close-button-active-bg-color); +} + +:is(.messageBar .closeButton):focus { + background-color: var(--message-bar-close-button-focus-bg-color); +} + +:is(.messageBar .closeButton) > span { + display: inline-block; + width: 0; + height: 0; + overflow: hidden; +} + +#editorUndoBar { + --csstools-light-dark-toggle--34: var(--csstools-color-scheme--light) #fbfbfe; + --text-primary-color: var(--csstools-light-dark-toggle--34, #15141a); + + --message-bar-icon: url(images/messageBar_info.svg); + --csstools-light-dark-toggle--35: var(--csstools-color-scheme--light) #73a7f3; + --message-bar-icon-color: var(--csstools-light-dark-toggle--35, #0060df); + --csstools-light-dark-toggle--36: var(--csstools-color-scheme--light) #003070; + --message-bar-bg-color: var(--csstools-light-dark-toggle--36, #deeafc); + --message-bar-fg-color: var(--text-primary-color); + --csstools-light-dark-toggle--37: var(--csstools-color-scheme--light) + rgb(255 255 255 / 0.08); + --message-bar-border-color: var( + --csstools-light-dark-toggle--37, + rgb(0 0 0 / 0.08) ); - --undo-button-bg-color:light-dark( - rgb(21 20 26 / 0.07), - rgb(255 255 255 / 0.08) + --csstools-light-dark-toggle--38: var(--csstools-color-scheme--light) + rgb(255 255 255 / 0.08); + + --undo-button-bg-color: var( + --csstools-light-dark-toggle--38, + rgb(21 20 26 / 0.07) ); - --undo-button-bg-color-hover:light-dark( - rgb(21 20 26 / 0.14), - rgb(255 255 255 / 0.14) + --csstools-light-dark-toggle--39: var(--csstools-color-scheme--light) + rgb(255 255 255 / 0.14); + --undo-button-bg-color-hover: var( + --csstools-light-dark-toggle--39, + rgb(21 20 26 / 0.14) ); - --undo-button-bg-color-active:light-dark( - rgb(21 20 26 / 0.21), - rgb(255 255 255 / 0.21) + --csstools-light-dark-toggle--40: var(--csstools-color-scheme--light) + rgb(255 255 255 / 0.21); + --undo-button-bg-color-active: var( + --csstools-light-dark-toggle--40, + rgb(21 20 26 / 0.21) ); -} + + --csstools-light-dark-toggle--41: var(--csstools-color-scheme--light) #0df; + + --undo-button-border: 1px solid var(--csstools-light-dark-toggle--41, #0060df); + + --undo-button-fg-color: var(--message-bar-fg-color); + --undo-button-fg-color-hover: var(--undo-button-fg-color); + --undo-button-fg-color-active: var(--undo-button-fg-color); } -@supports (color: light-dark(red, red)){ -#editorUndoBar{ - - --undo-button-border:1px solid light-dark(#0060df, #0df); -} -} - -@supports not (color: light-dark(tan, tan)){ - -#editorUndoBar *{ - --csstools-light-dark-toggle--34:var(--csstools-color-scheme--light) #fbfbfe; - --text-primary-color:var(--csstools-light-dark-toggle--34, #15141a); - --csstools-light-dark-toggle--35:var(--csstools-color-scheme--light) #73a7f3; - --message-bar-icon-color:var(--csstools-light-dark-toggle--35, #0060df); - --csstools-light-dark-toggle--36:var(--csstools-color-scheme--light) #003070; - --message-bar-bg-color:var(--csstools-light-dark-toggle--36, #deeafc); - --csstools-light-dark-toggle--37:var(--csstools-color-scheme--light) rgb(255 255 255 / 0.08); - --message-bar-border-color:var(--csstools-light-dark-toggle--37, rgb(0 0 0 / 0.08)); - - --csstools-light-dark-toggle--38:var(--csstools-color-scheme--light) rgb(255 255 255 / 0.08); - - --undo-button-bg-color:var(--csstools-light-dark-toggle--38, rgb(21 20 26 / 0.07)); - --csstools-light-dark-toggle--39:var(--csstools-color-scheme--light) rgb(255 255 255 / 0.14); - --undo-button-bg-color-hover:var(--csstools-light-dark-toggle--39, rgb(21 20 26 / 0.14)); - --csstools-light-dark-toggle--40:var(--csstools-color-scheme--light) rgb(255 255 255 / 0.21); - --undo-button-bg-color-active:var(--csstools-light-dark-toggle--40, rgb(21 20 26 / 0.21)); - - --csstools-light-dark-toggle--41:var(--csstools-color-scheme--light) #0df; - - --undo-button-border:1px solid var(--csstools-light-dark-toggle--41, #0060df); +@supports (color: light-dark(red, red)) { + #editorUndoBar { + --text-primary-color: light-dark(#15141a, #fbfbfe); + --message-bar-icon-color: light-dark(#0060df, #73a7f3); + --message-bar-bg-color: light-dark(#deeafc, #003070); } } -@media screen and (forced-colors: active){ +@supports (color: light-dark(red, red)) and (color: rgb(0 0 0 / 0)) { + #editorUndoBar { + --message-bar-border-color: light-dark( + rgb(0 0 0 / 0.08), + rgb(255 255 255 / 0.08) + ); -#editorUndoBar{ - --text-primary-color:CanvasText; - - --message-bar-icon-color:CanvasText; - --message-bar-bg-color:Canvas; - --message-bar-border-color:CanvasText; - - --undo-button-bg-color:ButtonText; - --undo-button-bg-color-hover:SelectedItem; - --undo-button-bg-color-active:SelectedItem; - - --undo-button-fg-color:ButtonFace; - --undo-button-fg-color-hover:SelectedItemText; - --undo-button-fg-color-active:SelectedItemText; - - --undo-button-border:none; -} + --undo-button-bg-color: light-dark( + rgb(21 20 26 / 0.07), + rgb(255 255 255 / 0.08) + ); + --undo-button-bg-color-hover: light-dark( + rgb(21 20 26 / 0.14), + rgb(255 255 255 / 0.14) + ); + --undo-button-bg-color-active: light-dark( + rgb(21 20 26 / 0.21), + rgb(255 255 255 / 0.21) + ); } - -#editorUndoBar{ - - position:fixed; - top:50px; - left:50%; - transform:translateX(-50%); - z-index:10; - - padding-block:8px; - padding-inline:16px 8px; - - font:menu; - font-size:15px; - - cursor:default; } -#editorUndoBar button{ - cursor:pointer; +@supports (color: light-dark(red, red)) { + #editorUndoBar { + --undo-button-border: 1px solid light-dark(#0060df, #0df); } +} -#editorUndoBar #editorUndoBarUndoButton{ - border-radius:4px; - font-weight:590; - line-height:19.5px; - color:var(--undo-button-fg-color); - border:var(--undo-button-border); - padding:4px 16px; - margin-inline-start:8px; - height:32px; +@supports not (color: light-dark(tan, tan)) { + #editorUndoBar * { + --csstools-light-dark-toggle--34: var(--csstools-color-scheme--light) + #fbfbfe; + --text-primary-color: var(--csstools-light-dark-toggle--34, #15141a); + --csstools-light-dark-toggle--35: var(--csstools-color-scheme--light) + #73a7f3; + --message-bar-icon-color: var(--csstools-light-dark-toggle--35, #0060df); + --csstools-light-dark-toggle--36: var(--csstools-color-scheme--light) + #003070; + --message-bar-bg-color: var(--csstools-light-dark-toggle--36, #deeafc); + --csstools-light-dark-toggle--37: var(--csstools-color-scheme--light) + rgb(255 255 255 / 0.08); + --message-bar-border-color: var( + --csstools-light-dark-toggle--37, + rgb(0 0 0 / 0.08) + ); - background-color:var(--undo-button-bg-color); + --csstools-light-dark-toggle--38: var(--csstools-color-scheme--light) + rgb(255 255 255 / 0.08); + + --undo-button-bg-color: var( + --csstools-light-dark-toggle--38, + rgb(21 20 26 / 0.07) + ); + --csstools-light-dark-toggle--39: var(--csstools-color-scheme--light) + rgb(255 255 255 / 0.14); + --undo-button-bg-color-hover: var( + --csstools-light-dark-toggle--39, + rgb(21 20 26 / 0.14) + ); + --csstools-light-dark-toggle--40: var(--csstools-color-scheme--light) + rgb(255 255 255 / 0.21); + --undo-button-bg-color-active: var( + --csstools-light-dark-toggle--40, + rgb(21 20 26 / 0.21) + ); + + --csstools-light-dark-toggle--41: var(--csstools-color-scheme--light) #0df; + + --undo-button-border: 1px solid + var(--csstools-light-dark-toggle--41, #0060df); } +} -:is(#editorUndoBar #editorUndoBarUndoButton):hover{ - background-color:var(--undo-button-bg-color-hover); - color:var(--undo-button-fg-color-hover); - } +@media screen and (forced-colors: active) { + #editorUndoBar { + --text-primary-color: CanvasText; -:is(#editorUndoBar #editorUndoBarUndoButton):active{ - background-color:var(--undo-button-bg-color-active); - color:var(--undo-button-fg-color-active); - } + --message-bar-icon-color: CanvasText; + --message-bar-bg-color: Canvas; + --message-bar-border-color: CanvasText; -#editorUndoBar > div{ - align-items:center; + --undo-button-bg-color: ButtonText; + --undo-button-bg-color-hover: SelectedItem; + --undo-button-bg-color-active: SelectedItem; + + --undo-button-fg-color: ButtonFace; + --undo-button-fg-color-hover: SelectedItemText; + --undo-button-fg-color-active: SelectedItemText; + + --undo-button-border: none; } +} -.dialog{ - --csstools-light-dark-toggle--42:var(--csstools-color-scheme--light) #1c1b22; - --dialog-bg-color:var(--csstools-light-dark-toggle--42, white); - --csstools-light-dark-toggle--43:var(--csstools-color-scheme--light) #1c1b22; - --dialog-border-color:var(--csstools-light-dark-toggle--43, white); - --csstools-light-dark-toggle--44:var(--csstools-color-scheme--light) #15141a; - --dialog-shadow:0 2px 14px 0 var(--csstools-light-dark-toggle--44, rgb(58 57 68 / 0.2)); - --csstools-light-dark-toggle--45:var(--csstools-color-scheme--light) #fbfbfe; - --text-primary-color:var(--csstools-light-dark-toggle--45, #15141a); - --csstools-light-dark-toggle--46:var(--csstools-color-scheme--light) #cfcfd8; - --text-secondary-color:var(--csstools-light-dark-toggle--46, #5b5b66); - --hover-filter:brightness(0.9); - --csstools-light-dark-toggle--47:var(--csstools-color-scheme--light) #0df; - --link-fg-color:var(--csstools-light-dark-toggle--47, #0060df); - --csstools-light-dark-toggle--48:var(--csstools-color-scheme--light) #80ebff; - --link-hover-fg-color:var(--csstools-light-dark-toggle--48, #0250bb); - --csstools-light-dark-toggle--49:var(--csstools-color-scheme--light) #52525e; - --separator-color:var(--csstools-light-dark-toggle--49, #f0f0f4); +#editorUndoBar { + position: fixed; + top: 50px; + left: 50%; + transform: translateX(-50%); + z-index: 10; - --textarea-border-color:#8f8f9d; - --csstools-light-dark-toggle--50:var(--csstools-color-scheme--light) #42414d; - --textarea-bg-color:var(--csstools-light-dark-toggle--50, white); - --textarea-fg-color:var(--text-secondary-color); + padding-block: 8px; + padding-inline: 16px 8px; - --csstools-light-dark-toggle--51:var(--csstools-color-scheme--light) #2b2a33; + font: menu; + font-size: 15px; - --radio-bg-color:var(--csstools-light-dark-toggle--51, #f0f0f4); - --csstools-light-dark-toggle--52:var(--csstools-color-scheme--light) #15141a; - --radio-checked-bg-color:var(--csstools-light-dark-toggle--52, #fbfbfe); - --radio-border-color:#8f8f9d; - --csstools-light-dark-toggle--53:var(--csstools-color-scheme--light) #0df; - --radio-checked-border-color:var(--csstools-light-dark-toggle--53, #0060df); + cursor: default; +} - --csstools-light-dark-toggle--54:var(--csstools-color-scheme--light) rgb(251 251 254 / 0.07); +#editorUndoBar button { + cursor: pointer; +} - --button-secondary-bg-color:var(--csstools-light-dark-toggle--54, rgb(21 20 26 / 0.07)); - --button-secondary-fg-color:var(--text-primary-color); - --button-secondary-border-color:var(--button-secondary-bg-color); - --csstools-light-dark-toggle--55:var(--csstools-color-scheme--light) rgb(251 251 254 / 0.21); - --button-secondary-active-bg-color:var(--csstools-light-dark-toggle--55, rgb(21 20 26 / 0.21)); - --button-secondary-active-fg-color:var(--button-secondary-fg-color); - --button-secondary-active-border-color:var(--button-secondary-bg-color); - --csstools-light-dark-toggle--56:var(--csstools-color-scheme--light) rgb(251 251 254 / 0.14); - --button-secondary-hover-bg-color:var(--csstools-light-dark-toggle--56, rgb(21 20 26 / 0.14)); - --button-secondary-hover-fg-color:var(--button-secondary-fg-color); - --button-secondary-hover-border-color:var(--button-secondary-hover-bg-color); - --button-secondary-disabled-bg-color:var(--button-secondary-bg-color); - --button-secondary-disabled-border-color:var( +#editorUndoBar #editorUndoBarUndoButton { + border-radius: 4px; + font-weight: 590; + line-height: 19.5px; + color: var(--undo-button-fg-color); + border: var(--undo-button-border); + padding: 4px 16px; + margin-inline-start: 8px; + height: 32px; + + background-color: var(--undo-button-bg-color); +} + +:is(#editorUndoBar #editorUndoBarUndoButton):hover { + background-color: var(--undo-button-bg-color-hover); + color: var(--undo-button-fg-color-hover); +} + +:is(#editorUndoBar #editorUndoBarUndoButton):active { + background-color: var(--undo-button-bg-color-active); + color: var(--undo-button-fg-color-active); +} + +#editorUndoBar > div { + align-items: center; +} + +.dialog { + --csstools-light-dark-toggle--42: var(--csstools-color-scheme--light) #1c1b22; + --dialog-bg-color: var(--csstools-light-dark-toggle--42, white); + --csstools-light-dark-toggle--43: var(--csstools-color-scheme--light) #1c1b22; + --dialog-border-color: var(--csstools-light-dark-toggle--43, white); + --csstools-light-dark-toggle--44: var(--csstools-color-scheme--light) #15141a; + --dialog-shadow: 0 2px 14px 0 + var(--csstools-light-dark-toggle--44, rgb(58 57 68 / 0.2)); + --csstools-light-dark-toggle--45: var(--csstools-color-scheme--light) #fbfbfe; + --text-primary-color: var(--csstools-light-dark-toggle--45, #15141a); + --csstools-light-dark-toggle--46: var(--csstools-color-scheme--light) #cfcfd8; + --text-secondary-color: var(--csstools-light-dark-toggle--46, #5b5b66); + --hover-filter: brightness(0.9); + --csstools-light-dark-toggle--47: var(--csstools-color-scheme--light) #0df; + --link-fg-color: var(--csstools-light-dark-toggle--47, #0060df); + --csstools-light-dark-toggle--48: var(--csstools-color-scheme--light) #80ebff; + --link-hover-fg-color: var(--csstools-light-dark-toggle--48, #0250bb); + --csstools-light-dark-toggle--49: var(--csstools-color-scheme--light) #52525e; + --separator-color: var(--csstools-light-dark-toggle--49, #f0f0f4); + + --textarea-border-color: #8f8f9d; + --csstools-light-dark-toggle--50: var(--csstools-color-scheme--light) #42414d; + --textarea-bg-color: var(--csstools-light-dark-toggle--50, white); + --textarea-fg-color: var(--text-secondary-color); + + --csstools-light-dark-toggle--51: var(--csstools-color-scheme--light) #2b2a33; + + --radio-bg-color: var(--csstools-light-dark-toggle--51, #f0f0f4); + --csstools-light-dark-toggle--52: var(--csstools-color-scheme--light) #15141a; + --radio-checked-bg-color: var(--csstools-light-dark-toggle--52, #fbfbfe); + --radio-border-color: #8f8f9d; + --csstools-light-dark-toggle--53: var(--csstools-color-scheme--light) #0df; + --radio-checked-border-color: var(--csstools-light-dark-toggle--53, #0060df); + + --csstools-light-dark-toggle--54: var(--csstools-color-scheme--light) + rgb(251 251 254 / 0.07); + + --button-secondary-bg-color: var( + --csstools-light-dark-toggle--54, + rgb(21 20 26 / 0.07) + ); + --button-secondary-fg-color: var(--text-primary-color); + --button-secondary-border-color: var(--button-secondary-bg-color); + --csstools-light-dark-toggle--55: var(--csstools-color-scheme--light) + rgb(251 251 254 / 0.21); + --button-secondary-active-bg-color: var( + --csstools-light-dark-toggle--55, + rgb(21 20 26 / 0.21) + ); + --button-secondary-active-fg-color: var(--button-secondary-fg-color); + --button-secondary-active-border-color: var(--button-secondary-bg-color); + --csstools-light-dark-toggle--56: var(--csstools-color-scheme--light) + rgb(251 251 254 / 0.14); + --button-secondary-hover-bg-color: var( + --csstools-light-dark-toggle--56, + rgb(21 20 26 / 0.14) + ); + --button-secondary-hover-fg-color: var(--button-secondary-fg-color); + --button-secondary-hover-border-color: var(--button-secondary-hover-bg-color); + --button-secondary-disabled-bg-color: var(--button-secondary-bg-color); + --button-secondary-disabled-border-color: var( --button-secondary-border-color ); - --button-secondary-disabled-fg-color:var(--button-secondary-fg-color); + --button-secondary-disabled-fg-color: var(--button-secondary-fg-color); - --csstools-light-dark-toggle--57:var(--csstools-color-scheme--light) #0df; + --csstools-light-dark-toggle--57: var(--csstools-color-scheme--light) #0df; - --button-primary-bg-color:var(--csstools-light-dark-toggle--57, #0060df); - --csstools-light-dark-toggle--58:var(--csstools-color-scheme--light) #15141a; - --button-primary-fg-color:var(--csstools-light-dark-toggle--58, #fbfbfe); - --button-primary-border-color:var(--button-primary-bg-color); - --csstools-light-dark-toggle--59:var(--csstools-color-scheme--light) #aaf2ff; - --button-primary-active-bg-color:var(--csstools-light-dark-toggle--59, #054096); - --button-primary-active-fg-color:var(--button-primary-fg-color); - --button-primary-active-border-color:var(--button-primary-active-bg-color); - --csstools-light-dark-toggle--60:var(--csstools-color-scheme--light) #80ebff; - --button-primary-hover-bg-color:var(--csstools-light-dark-toggle--60, #0250bb); - --button-primary-hover-fg-color:var(--button-primary-fg-color); - --button-primary-hover-border-color:var(--button-primary-hover-bg-color); - --button-primary-disabled-bg-color:var(--button-primary-bg-color); - --button-primary-disabled-border-color:var(--button-primary-border-color); - --button-primary-disabled-fg-color:var(--button-primary-fg-color); - --button-disabled-opacity:0.4; - - --csstools-light-dark-toggle--61:var(--csstools-color-scheme--light) #42414d; - - --input-text-bg-color:var(--csstools-light-dark-toggle--61, white); - --input-text-fg-color:var(--text-primary-color); -} - -@supports (color: light-dark(red, red)){ -.dialog{ - --dialog-bg-color:light-dark(white, #1c1b22); - --dialog-border-color:light-dark(white, #1c1b22); -} -} - -@supports (color: light-dark(red, red)) and (color: rgb(0 0 0 / 0)){ -.dialog{ - --dialog-shadow:0 2px 14px 0 light-dark(rgb(58 57 68 / 0.2), #15141a); -} -} - -@supports (color: light-dark(red, red)){ -.dialog{ - --text-primary-color:light-dark(#15141a, #fbfbfe); - --text-secondary-color:light-dark(#5b5b66, #cfcfd8); - --link-fg-color:light-dark(#0060df, #0df); - --link-hover-fg-color:light-dark(#0250bb, #80ebff); - --separator-color:light-dark(#f0f0f4, #52525e); - --textarea-bg-color:light-dark(white, #42414d); - - --radio-bg-color:light-dark(#f0f0f4, #2b2a33); - --radio-checked-bg-color:light-dark(#fbfbfe, #15141a); - --radio-checked-border-color:light-dark(#0060df, #0df); -} -} - -@supports (color: light-dark(red, red)) and (color: rgb(0 0 0 / 0)){ -.dialog{ - - --button-secondary-bg-color:light-dark( - rgb(21 20 26 / 0.07), - rgb(251 251 254 / 0.07) + --button-primary-bg-color: var(--csstools-light-dark-toggle--57, #0060df); + --csstools-light-dark-toggle--58: var(--csstools-color-scheme--light) #15141a; + --button-primary-fg-color: var(--csstools-light-dark-toggle--58, #fbfbfe); + --button-primary-border-color: var(--button-primary-bg-color); + --csstools-light-dark-toggle--59: var(--csstools-color-scheme--light) #aaf2ff; + --button-primary-active-bg-color: var( + --csstools-light-dark-toggle--59, + #054096 ); - --button-secondary-active-bg-color:light-dark( - rgb(21 20 26 / 0.21), - rgb(251 251 254 / 0.21) + --button-primary-active-fg-color: var(--button-primary-fg-color); + --button-primary-active-border-color: var(--button-primary-active-bg-color); + --csstools-light-dark-toggle--60: var(--csstools-color-scheme--light) #80ebff; + --button-primary-hover-bg-color: var( + --csstools-light-dark-toggle--60, + #0250bb ); - --button-secondary-hover-bg-color:light-dark( - rgb(21 20 26 / 0.14), - rgb(251 251 254 / 0.14) + --button-primary-hover-fg-color: var(--button-primary-fg-color); + --button-primary-hover-border-color: var(--button-primary-hover-bg-color); + --button-primary-disabled-bg-color: var(--button-primary-bg-color); + --button-primary-disabled-border-color: var(--button-primary-border-color); + --button-primary-disabled-fg-color: var(--button-primary-fg-color); + --button-disabled-opacity: 0.4; + + --csstools-light-dark-toggle--61: var(--csstools-color-scheme--light) #42414d; + + --input-text-bg-color: var(--csstools-light-dark-toggle--61, white); + --input-text-fg-color: var(--text-primary-color); +} + +@supports (color: light-dark(red, red)) { + .dialog { + --dialog-bg-color: light-dark(white, #1c1b22); + --dialog-border-color: light-dark(white, #1c1b22); + } +} + +@supports (color: light-dark(red, red)) and (color: rgb(0 0 0 / 0)) { + .dialog { + --dialog-shadow: 0 2px 14px 0 light-dark(rgb(58 57 68 / 0.2), #15141a); + } +} + +@supports (color: light-dark(red, red)) { + .dialog { + --text-primary-color: light-dark(#15141a, #fbfbfe); + --text-secondary-color: light-dark(#5b5b66, #cfcfd8); + --link-fg-color: light-dark(#0060df, #0df); + --link-hover-fg-color: light-dark(#0250bb, #80ebff); + --separator-color: light-dark(#f0f0f4, #52525e); + --textarea-bg-color: light-dark(white, #42414d); + + --radio-bg-color: light-dark(#f0f0f4, #2b2a33); + --radio-checked-bg-color: light-dark(#fbfbfe, #15141a); + --radio-checked-border-color: light-dark(#0060df, #0df); + } +} + +@supports (color: light-dark(red, red)) and (color: rgb(0 0 0 / 0)) { + .dialog { + --button-secondary-bg-color: light-dark( + rgb(21 20 26 / 0.07), + rgb(251 251 254 / 0.07) + ); + --button-secondary-active-bg-color: light-dark( + rgb(21 20 26 / 0.21), + rgb(251 251 254 / 0.21) + ); + --button-secondary-hover-bg-color: light-dark( + rgb(21 20 26 / 0.14), + rgb(251 251 254 / 0.14) + ); + } +} + +@supports (color: light-dark(red, red)) { + .dialog { + --button-primary-bg-color: light-dark(#0060df, #0df); + --button-primary-fg-color: light-dark(#fbfbfe, #15141a); + --button-primary-active-bg-color: light-dark(#054096, #aaf2ff); + --button-primary-hover-bg-color: light-dark(#0250bb, #80ebff); + + --input-text-bg-color: light-dark(white, #42414d); + } +} + +@supports not (color: light-dark(tan, tan)) { + .dialog * { + --csstools-light-dark-toggle--42: var(--csstools-color-scheme--light) + #1c1b22; + --dialog-bg-color: var(--csstools-light-dark-toggle--42, white); + --csstools-light-dark-toggle--43: var(--csstools-color-scheme--light) + #1c1b22; + --dialog-border-color: var(--csstools-light-dark-toggle--43, white); + --csstools-light-dark-toggle--44: var(--csstools-color-scheme--light) + #15141a; + --dialog-shadow: 0 2px 14px 0 + var(--csstools-light-dark-toggle--44, rgb(58 57 68 / 0.2)); + --csstools-light-dark-toggle--45: var(--csstools-color-scheme--light) + #fbfbfe; + --text-primary-color: var(--csstools-light-dark-toggle--45, #15141a); + --csstools-light-dark-toggle--46: var(--csstools-color-scheme--light) + #cfcfd8; + --text-secondary-color: var(--csstools-light-dark-toggle--46, #5b5b66); + --csstools-light-dark-toggle--47: var(--csstools-color-scheme--light) #0df; + --link-fg-color: var(--csstools-light-dark-toggle--47, #0060df); + --csstools-light-dark-toggle--48: var(--csstools-color-scheme--light) + #80ebff; + --link-hover-fg-color: var(--csstools-light-dark-toggle--48, #0250bb); + --csstools-light-dark-toggle--49: var(--csstools-color-scheme--light) + #52525e; + --separator-color: var(--csstools-light-dark-toggle--49, #f0f0f4); + --csstools-light-dark-toggle--50: var(--csstools-color-scheme--light) + #42414d; + --textarea-bg-color: var(--csstools-light-dark-toggle--50, white); + + --csstools-light-dark-toggle--51: var(--csstools-color-scheme--light) + #2b2a33; + + --radio-bg-color: var(--csstools-light-dark-toggle--51, #f0f0f4); + --csstools-light-dark-toggle--52: var(--csstools-color-scheme--light) + #15141a; + --radio-checked-bg-color: var(--csstools-light-dark-toggle--52, #fbfbfe); + --csstools-light-dark-toggle--53: var(--csstools-color-scheme--light) #0df; + --radio-checked-border-color: var( + --csstools-light-dark-toggle--53, + #0060df + ); + + --csstools-light-dark-toggle--54: var(--csstools-color-scheme--light) + rgb(251 251 254 / 0.07); + + --button-secondary-bg-color: var( + --csstools-light-dark-toggle--54, + rgb(21 20 26 / 0.07) + ); + --csstools-light-dark-toggle--55: var(--csstools-color-scheme--light) + rgb(251 251 254 / 0.21); + --button-secondary-active-bg-color: var( + --csstools-light-dark-toggle--55, + rgb(21 20 26 / 0.21) + ); + --csstools-light-dark-toggle--56: var(--csstools-color-scheme--light) + rgb(251 251 254 / 0.14); + --button-secondary-hover-bg-color: var( + --csstools-light-dark-toggle--56, + rgb(21 20 26 / 0.14) + ); + + --csstools-light-dark-toggle--57: var(--csstools-color-scheme--light) #0df; + + --button-primary-bg-color: var(--csstools-light-dark-toggle--57, #0060df); + --csstools-light-dark-toggle--58: var(--csstools-color-scheme--light) + #15141a; + --button-primary-fg-color: var(--csstools-light-dark-toggle--58, #fbfbfe); + --csstools-light-dark-toggle--59: var(--csstools-color-scheme--light) + #aaf2ff; + --button-primary-active-bg-color: var( + --csstools-light-dark-toggle--59, + #054096 + ); + --csstools-light-dark-toggle--60: var(--csstools-color-scheme--light) + #80ebff; + --button-primary-hover-bg-color: var( + --csstools-light-dark-toggle--60, + #0250bb + ); + + --csstools-light-dark-toggle--61: var(--csstools-color-scheme--light) + #42414d; + + --input-text-bg-color: var(--csstools-light-dark-toggle--61, white); + } +} + +@media (prefers-color-scheme: dark) { + .dialog { + --hover-filter: brightness(1.4); + --button-disabled-opacity: 0.6; + } +} + +@media screen and (forced-colors: active) { + .dialog { + --dialog-bg-color: Canvas; + --dialog-border-color: CanvasText; + --dialog-shadow: none; + --text-primary-color: CanvasText; + --text-secondary-color: CanvasText; + --hover-filter: none; + --link-fg-color: LinkText; + --link-hover-fg-color: LinkText; + --separator-color: CanvasText; + + --textarea-border-color: ButtonBorder; + --textarea-bg-color: Field; + --textarea-fg-color: ButtonText; + + --radio-bg-color: ButtonFace; + --radio-checked-bg-color: ButtonFace; + --radio-border-color: ButtonText; + --radio-checked-border-color: ButtonText; + + --button-secondary-bg-color: ButtonFace; + --button-secondary-fg-color: ButtonText; + --button-secondary-border-color: ButtonText; + --button-secondary-active-bg-color: HighlightText; + --button-secondary-active-fg-color: SelectedItem; + --button-secondary-active-border-color: ButtonText; + --button-secondary-hover-bg-color: HighlightText; + --button-secondary-hover-fg-color: SelectedItem; + --button-secondary-hover-border-color: SelectedItem; + --button-secondary-disabled-fg-color: GrayText; + --button-secondary-disabled-border-color: GrayText; + + --button-primary-bg-color: ButtonText; + --button-primary-fg-color: ButtonFace; + --button-primary-border-color: ButtonText; + --button-primary-active-bg-color: SelectedItem; + --button-primary-active-fg-color: HighlightText; + --button-primary-active-border-color: ButtonText; + --button-primary-hover-bg-color: SelectedItem; + --button-primary-hover-fg-color: HighlightText; + --button-primary-hover-border-color: SelectedItem; + --button-primary-disabled-bg-color: GrayText; + --button-primary-disabled-fg-color: ButtonFace; + --button-primary-disabled-border-color: GrayText; + --button-disabled-opacity: 1; + + --input-text-bg-color: Field; + --input-text-fg-color: FieldText; + } +} + +.dialog { + font: message-box; + font-size: 13px; + font-weight: 400; + line-height: 150%; + border-radius: 4px; + padding: 12px 16px; + border: 1px solid var(--dialog-border-color); + background: var(--dialog-bg-color); + color: var(--text-primary-color); + box-shadow: var(--dialog-shadow); +} + +:is(.dialog .mainContainer) *:focus-visible { + outline: var(--focus-ring-outline); + outline-offset: 2px; +} + +:is(.dialog .mainContainer) .title { + display: flex; + width: auto; + flex-direction: column; + justify-content: flex-end; + align-items: flex-start; + gap: 12px; +} + +:is(:is(.dialog .mainContainer) .title) > span { + font-size: 13px; + font-style: normal; + font-weight: 590; + line-height: 150%; +} + +:is(.dialog .mainContainer) .dialogSeparator { + width: 100%; + height: 0; + margin-block: 4px; + border-top: 1px solid var(--separator-color); + border-bottom: none; +} + +:is(.dialog .mainContainer) .dialogButtonsGroup { + display: flex; + gap: 12px; + align-self: flex-end; +} + +:is(.dialog .mainContainer) .radio { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 4px; +} + +:is(:is(.dialog .mainContainer) .radio) > .radioButton { + display: flex; + gap: 8px; + align-self: stretch; + align-items: center; +} + +:is(:is(:is(.dialog .mainContainer) .radio) > .radioButton) input { + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + box-sizing: border-box; + width: 16px; + height: 16px; + border-radius: 50%; + background-color: var(--radio-bg-color); + border: 1px solid var(--radio-border-color); +} + +:is(:is(:is(:is(.dialog .mainContainer) .radio) > .radioButton) input):hover { + filter: var(--hover-filter); +} + +:is(:is(:is(:is(.dialog .mainContainer) .radio) > .radioButton) input):checked { + background-color: var(--radio-checked-bg-color); + border: 4px solid var(--radio-checked-border-color); +} + +:is(:is(.dialog .mainContainer) .radio) > .radioLabel { + display: flex; + padding-inline-start: 24px; + align-items: flex-start; + gap: 10px; + align-self: stretch; +} + +:is(:is(:is(.dialog .mainContainer) .radio) > .radioLabel) > span { + flex: 1 0 0; + font-size: 11px; + color: var(--text-secondary-color); +} + +:is(.dialog .mainContainer) + button:not(:is(.toggle-button, .closeButton, .clearInputButton)) { + border-radius: 4px; + border: 1px solid; + font: menu; + font-weight: 590; + font-size: 13px; + padding: 4px 16px; + width: auto; + height: 32px; +} + +:is( + :is(.dialog .mainContainer) + button:not(:is(.toggle-button, .closeButton, .clearInputButton)) +):hover { + cursor: pointer; + filter: var(--hover-filter); +} + +:is( + :is(.dialog .mainContainer) + button:not(:is(.toggle-button, .closeButton, .clearInputButton)) + ) + > span { + color: inherit; + font: inherit; +} + +.secondaryButton:is( + :is(.dialog .mainContainer) + button:not(:is(.toggle-button, .closeButton, .clearInputButton)) +) { + color: var(--button-secondary-fg-color); + background-color: var(--button-secondary-bg-color); + border-color: var(--button-secondary-border-color); +} + +.secondaryButton:is( + :is(.dialog .mainContainer) + button:not(:is(.toggle-button, .closeButton, .clearInputButton)) + ):hover { + color: var(--button-secondary-hover-fg-color); + background-color: var(--button-secondary-hover-bg-color); + border-color: var(--button-secondary-hover-border-color); +} + +.secondaryButton:is( + :is(.dialog .mainContainer) + button:not(:is(.toggle-button, .closeButton, .clearInputButton)) + ):active { + color: var(--button-secondary-active-fg-color); + background-color: var(--button-secondary-active-bg-color); + border-color: var(--button-secondary-active-border-color); +} + +.secondaryButton:is( + :is(.dialog .mainContainer) + button:not(:is(.toggle-button, .closeButton, .clearInputButton)) + ):disabled { + background-color: var(--button-secondary-disabled-bg-color); + border-color: var(--button-secondary-disabled-border-color); + color: var(--button-secondary-disabled-fg-color); + opacity: var(--button-disabled-opacity); +} + +.primaryButton:is( + :is(.dialog .mainContainer) + button:not(:is(.toggle-button, .closeButton, .clearInputButton)) +) { + color: var(--button-primary-fg-color); + background-color: var(--button-primary-bg-color); + border-color: var(--button-primary-border-color); + opacity: 1; +} + +.primaryButton:is( + :is(.dialog .mainContainer) + button:not(:is(.toggle-button, .closeButton, .clearInputButton)) + ):hover { + color: var(--button-primary-hover-fg-color); + background-color: var(--button-primary-hover-bg-color); + border-color: var(--button-primary-hover-border-color); +} + +.primaryButton:is( + :is(.dialog .mainContainer) + button:not(:is(.toggle-button, .closeButton, .clearInputButton)) + ):active { + color: var(--button-primary-active-fg-color); + background-color: var(--button-primary-active-bg-color); + border-color: var(--button-primary-active-border-color); +} + +.primaryButton:is( + :is(.dialog .mainContainer) + button:not(:is(.toggle-button, .closeButton, .clearInputButton)) + ):disabled { + background-color: var(--button-primary-disabled-bg-color); + border-color: var(--button-primary-disabled-border-color); + color: var(--button-primary-disabled-fg-color); + opacity: var(--button-disabled-opacity); +} + +:is( + :is(.dialog .mainContainer) + button:not(:is(.toggle-button, .closeButton, .clearInputButton)) +):disabled { + pointer-events: none; +} + +:is(.dialog .mainContainer) a { + color: var(--link-fg-color); +} + +:is(:is(.dialog .mainContainer) a):hover { + color: var(--link-hover-fg-color); +} + +:is(.dialog .mainContainer) textarea { + font: inherit; + padding: 8px; + resize: none; + margin: 0; + box-sizing: border-box; + border-radius: 4px; + border: 1px solid var(--textarea-border-color); + background: var(--textarea-bg-color); + color: var(--textarea-fg-color); +} + +:is(:is(.dialog .mainContainer) textarea):focus { + outline-offset: 0; + border-color: transparent; +} + +:is(:is(.dialog .mainContainer) textarea):disabled { + pointer-events: none; + opacity: 0.4; +} + +:is(.dialog .mainContainer) input[type='text'] { + background-color: var(--input-text-bg-color); + color: var(--input-text-fg-color); +} + +:is(.dialog .mainContainer) .messageBar { + --csstools-light-dark-toggle--62: var(--csstools-color-scheme--light) #5a3100; + --message-bar-bg-color: var(--csstools-light-dark-toggle--62, #ffebcd); + --csstools-light-dark-toggle--63: var(--csstools-color-scheme--light) #fbfbfe; + --message-bar-fg-color: var(--csstools-light-dark-toggle--63, #15141a); + --csstools-light-dark-toggle--64: var(--csstools-color-scheme--light) + rgb(255 255 255 / 0.08); + --message-bar-border-color: var( + --csstools-light-dark-toggle--64, + rgb(0 0 0 / 0.08) ); -} + --message-bar-icon: url(images/messageBar_warning.svg); + --csstools-light-dark-toggle--65: var(--csstools-color-scheme--light) #e49c49; + --message-bar-icon-color: var(--csstools-light-dark-toggle--65, #cd411e); } -@supports (color: light-dark(red, red)){ -.dialog{ - - --button-primary-bg-color:light-dark(#0060df, #0df); - --button-primary-fg-color:light-dark(#fbfbfe, #15141a); - --button-primary-active-bg-color:light-dark(#054096, #aaf2ff); - --button-primary-hover-bg-color:light-dark(#0250bb, #80ebff); - - --input-text-bg-color:light-dark(white, #42414d); -} -} - -@supports not (color: light-dark(tan, tan)){ - -.dialog *{ - --csstools-light-dark-toggle--42:var(--csstools-color-scheme--light) #1c1b22; - --dialog-bg-color:var(--csstools-light-dark-toggle--42, white); - --csstools-light-dark-toggle--43:var(--csstools-color-scheme--light) #1c1b22; - --dialog-border-color:var(--csstools-light-dark-toggle--43, white); - --csstools-light-dark-toggle--44:var(--csstools-color-scheme--light) #15141a; - --dialog-shadow:0 2px 14px 0 var(--csstools-light-dark-toggle--44, rgb(58 57 68 / 0.2)); - --csstools-light-dark-toggle--45:var(--csstools-color-scheme--light) #fbfbfe; - --text-primary-color:var(--csstools-light-dark-toggle--45, #15141a); - --csstools-light-dark-toggle--46:var(--csstools-color-scheme--light) #cfcfd8; - --text-secondary-color:var(--csstools-light-dark-toggle--46, #5b5b66); - --csstools-light-dark-toggle--47:var(--csstools-color-scheme--light) #0df; - --link-fg-color:var(--csstools-light-dark-toggle--47, #0060df); - --csstools-light-dark-toggle--48:var(--csstools-color-scheme--light) #80ebff; - --link-hover-fg-color:var(--csstools-light-dark-toggle--48, #0250bb); - --csstools-light-dark-toggle--49:var(--csstools-color-scheme--light) #52525e; - --separator-color:var(--csstools-light-dark-toggle--49, #f0f0f4); - --csstools-light-dark-toggle--50:var(--csstools-color-scheme--light) #42414d; - --textarea-bg-color:var(--csstools-light-dark-toggle--50, white); - - --csstools-light-dark-toggle--51:var(--csstools-color-scheme--light) #2b2a33; - - --radio-bg-color:var(--csstools-light-dark-toggle--51, #f0f0f4); - --csstools-light-dark-toggle--52:var(--csstools-color-scheme--light) #15141a; - --radio-checked-bg-color:var(--csstools-light-dark-toggle--52, #fbfbfe); - --csstools-light-dark-toggle--53:var(--csstools-color-scheme--light) #0df; - --radio-checked-border-color:var(--csstools-light-dark-toggle--53, #0060df); - - --csstools-light-dark-toggle--54:var(--csstools-color-scheme--light) rgb(251 251 254 / 0.07); - - --button-secondary-bg-color:var(--csstools-light-dark-toggle--54, rgb(21 20 26 / 0.07)); - --csstools-light-dark-toggle--55:var(--csstools-color-scheme--light) rgb(251 251 254 / 0.21); - --button-secondary-active-bg-color:var(--csstools-light-dark-toggle--55, rgb(21 20 26 / 0.21)); - --csstools-light-dark-toggle--56:var(--csstools-color-scheme--light) rgb(251 251 254 / 0.14); - --button-secondary-hover-bg-color:var(--csstools-light-dark-toggle--56, rgb(21 20 26 / 0.14)); - - --csstools-light-dark-toggle--57:var(--csstools-color-scheme--light) #0df; - - --button-primary-bg-color:var(--csstools-light-dark-toggle--57, #0060df); - --csstools-light-dark-toggle--58:var(--csstools-color-scheme--light) #15141a; - --button-primary-fg-color:var(--csstools-light-dark-toggle--58, #fbfbfe); - --csstools-light-dark-toggle--59:var(--csstools-color-scheme--light) #aaf2ff; - --button-primary-active-bg-color:var(--csstools-light-dark-toggle--59, #054096); - --csstools-light-dark-toggle--60:var(--csstools-color-scheme--light) #80ebff; - --button-primary-hover-bg-color:var(--csstools-light-dark-toggle--60, #0250bb); - - --csstools-light-dark-toggle--61:var(--csstools-color-scheme--light) #42414d; - - --input-text-bg-color:var(--csstools-light-dark-toggle--61, white); +@supports (color: light-dark(red, red)) { + :is(.dialog .mainContainer) .messageBar { + --message-bar-bg-color: light-dark(#ffebcd, #5a3100); + --message-bar-fg-color: light-dark(#15141a, #fbfbfe); } } -@media (prefers-color-scheme: dark){ - -.dialog{ - --hover-filter:brightness(1.4); - --button-disabled-opacity:0.6; -} - } - -@media screen and (forced-colors: active){ - -.dialog{ - --dialog-bg-color:Canvas; - --dialog-border-color:CanvasText; - --dialog-shadow:none; - --text-primary-color:CanvasText; - --text-secondary-color:CanvasText; - --hover-filter:none; - --link-fg-color:LinkText; - --link-hover-fg-color:LinkText; - --separator-color:CanvasText; - - --textarea-border-color:ButtonBorder; - --textarea-bg-color:Field; - --textarea-fg-color:ButtonText; - - --radio-bg-color:ButtonFace; - --radio-checked-bg-color:ButtonFace; - --radio-border-color:ButtonText; - --radio-checked-border-color:ButtonText; - - --button-secondary-bg-color:ButtonFace; - --button-secondary-fg-color:ButtonText; - --button-secondary-border-color:ButtonText; - --button-secondary-active-bg-color:HighlightText; - --button-secondary-active-fg-color:SelectedItem; - --button-secondary-active-border-color:ButtonText; - --button-secondary-hover-bg-color:HighlightText; - --button-secondary-hover-fg-color:SelectedItem; - --button-secondary-hover-border-color:SelectedItem; - --button-secondary-disabled-fg-color:GrayText; - --button-secondary-disabled-border-color:GrayText; - - --button-primary-bg-color:ButtonText; - --button-primary-fg-color:ButtonFace; - --button-primary-border-color:ButtonText; - --button-primary-active-bg-color:SelectedItem; - --button-primary-active-fg-color:HighlightText; - --button-primary-active-border-color:ButtonText; - --button-primary-hover-bg-color:SelectedItem; - --button-primary-hover-fg-color:HighlightText; - --button-primary-hover-border-color:SelectedItem; - --button-primary-disabled-bg-color:GrayText; - --button-primary-disabled-fg-color:ButtonFace; - --button-primary-disabled-border-color:GrayText; - --button-disabled-opacity:1; - - --input-text-bg-color:Field; - --input-text-fg-color:FieldText; -} - } - -.dialog{ - - font:message-box; - font-size:13px; - font-weight:400; - line-height:150%; - border-radius:4px; - padding:12px 16px; - border:1px solid var(--dialog-border-color); - background:var(--dialog-bg-color); - color:var(--text-primary-color); - box-shadow:var(--dialog-shadow); -} - -:is(.dialog .mainContainer) *:focus-visible{ - outline:var(--focus-ring-outline); - outline-offset:2px; - } - -:is(.dialog .mainContainer) .title{ - display:flex; - width:auto; - flex-direction:column; - justify-content:flex-end; - align-items:flex-start; - gap:12px; - } - -:is(:is(.dialog .mainContainer) .title) > span{ - font-size:13px; - font-style:normal; - font-weight:590; - line-height:150%; - } - -:is(.dialog .mainContainer) .dialogSeparator{ - width:100%; - height:0; - margin-block:4px; - border-top:1px solid var(--separator-color); - border-bottom:none; - } - -:is(.dialog .mainContainer) .dialogButtonsGroup{ - display:flex; - gap:12px; - align-self:flex-end; - } - -:is(.dialog .mainContainer) .radio{ - display:flex; - flex-direction:column; - align-items:flex-start; - gap:4px; - } - -:is(:is(.dialog .mainContainer) .radio) > .radioButton{ - display:flex; - gap:8px; - align-self:stretch; - align-items:center; - } - -:is(:is(:is(.dialog .mainContainer) .radio) > .radioButton) input{ - -webkit-appearance:none; - -moz-appearance:none; - appearance:none; - box-sizing:border-box; - width:16px; - height:16px; - border-radius:50%; - background-color:var(--radio-bg-color); - border:1px solid var(--radio-border-color); - } - -:is(:is(:is(:is(.dialog .mainContainer) .radio) > .radioButton) input):hover{ - filter:var(--hover-filter); - } - -:is(:is(:is(:is(.dialog .mainContainer) .radio) > .radioButton) input):checked{ - background-color:var(--radio-checked-bg-color); - border:4px solid var(--radio-checked-border-color); - } - -:is(:is(.dialog .mainContainer) .radio) > .radioLabel{ - display:flex; - padding-inline-start:24px; - align-items:flex-start; - gap:10px; - align-self:stretch; - } - -:is(:is(:is(.dialog .mainContainer) .radio) > .radioLabel) > span{ - flex:1 0 0; - font-size:11px; - color:var(--text-secondary-color); - } - -:is(.dialog .mainContainer) button:not(:is(.toggle-button,.closeButton,.clearInputButton)){ - border-radius:4px; - border:1px solid; - font:menu; - font-weight:590; - font-size:13px; - padding:4px 16px; - width:auto; - height:32px; - } - -:is(:is(.dialog .mainContainer) button:not(:is(.toggle-button,.closeButton,.clearInputButton))):hover{ - cursor:pointer; - filter:var(--hover-filter); - } - -:is(:is(.dialog .mainContainer) button:not(:is(.toggle-button,.closeButton,.clearInputButton))) > span{ - color:inherit; - font:inherit; - } - -.secondaryButton:is(:is(.dialog .mainContainer) button:not(:is(.toggle-button,.closeButton,.clearInputButton))){ - color:var(--button-secondary-fg-color); - background-color:var(--button-secondary-bg-color); - border-color:var(--button-secondary-border-color); - } - -.secondaryButton:is(:is(.dialog .mainContainer) button:not(:is(.toggle-button,.closeButton,.clearInputButton))):hover{ - color:var(--button-secondary-hover-fg-color); - background-color:var(--button-secondary-hover-bg-color); - border-color:var(--button-secondary-hover-border-color); - } - -.secondaryButton:is(:is(.dialog .mainContainer) button:not(:is(.toggle-button,.closeButton,.clearInputButton))):active{ - color:var(--button-secondary-active-fg-color); - background-color:var(--button-secondary-active-bg-color); - border-color:var(--button-secondary-active-border-color); - } - -.secondaryButton:is(:is(.dialog .mainContainer) button:not(:is(.toggle-button,.closeButton,.clearInputButton))):disabled{ - background-color:var(--button-secondary-disabled-bg-color); - border-color:var(--button-secondary-disabled-border-color); - color:var(--button-secondary-disabled-fg-color); - opacity:var(--button-disabled-opacity); - } - -.primaryButton:is(:is(.dialog .mainContainer) button:not(:is(.toggle-button,.closeButton,.clearInputButton))){ - color:var(--button-primary-fg-color); - background-color:var(--button-primary-bg-color); - border-color:var(--button-primary-border-color); - opacity:1; - } - -.primaryButton:is(:is(.dialog .mainContainer) button:not(:is(.toggle-button,.closeButton,.clearInputButton))):hover{ - color:var(--button-primary-hover-fg-color); - background-color:var(--button-primary-hover-bg-color); - border-color:var(--button-primary-hover-border-color); - } - -.primaryButton:is(:is(.dialog .mainContainer) button:not(:is(.toggle-button,.closeButton,.clearInputButton))):active{ - color:var(--button-primary-active-fg-color); - background-color:var(--button-primary-active-bg-color); - border-color:var(--button-primary-active-border-color); - } - -.primaryButton:is(:is(.dialog .mainContainer) button:not(:is(.toggle-button,.closeButton,.clearInputButton))):disabled{ - background-color:var(--button-primary-disabled-bg-color); - border-color:var(--button-primary-disabled-border-color); - color:var(--button-primary-disabled-fg-color); - opacity:var(--button-disabled-opacity); - } - -:is(:is(.dialog .mainContainer) button:not(:is(.toggle-button,.closeButton,.clearInputButton))):disabled{ - pointer-events:none; - } - -:is(.dialog .mainContainer) a{ - color:var(--link-fg-color); - } - -:is(:is(.dialog .mainContainer) a):hover{ - color:var(--link-hover-fg-color); - } - -:is(.dialog .mainContainer) textarea{ - font:inherit; - padding:8px; - resize:none; - margin:0; - box-sizing:border-box; - border-radius:4px; - border:1px solid var(--textarea-border-color); - background:var(--textarea-bg-color); - color:var(--textarea-fg-color); - } - -:is(:is(.dialog .mainContainer) textarea):focus{ - outline-offset:0; - border-color:transparent; - } - -:is(:is(.dialog .mainContainer) textarea):disabled{ - pointer-events:none; - opacity:0.4; - } - -:is(.dialog .mainContainer) input[type="text"]{ - background-color:var(--input-text-bg-color); - color:var(--input-text-fg-color); - } - -:is(.dialog .mainContainer) .messageBar{ - --csstools-light-dark-toggle--62:var(--csstools-color-scheme--light) #5a3100; - --message-bar-bg-color:var(--csstools-light-dark-toggle--62, #ffebcd); - --csstools-light-dark-toggle--63:var(--csstools-color-scheme--light) #fbfbfe; - --message-bar-fg-color:var(--csstools-light-dark-toggle--63, #15141a); - --csstools-light-dark-toggle--64:var(--csstools-color-scheme--light) rgb(255 255 255 / 0.08); - --message-bar-border-color:var(--csstools-light-dark-toggle--64, rgb(0 0 0 / 0.08)); - --message-bar-icon:url(images/messageBar_warning.svg); - --csstools-light-dark-toggle--65:var(--csstools-color-scheme--light) #e49c49; - --message-bar-icon-color:var(--csstools-light-dark-toggle--65, #cd411e); - } - -@supports (color: light-dark(red, red)){ -:is(.dialog .mainContainer) .messageBar{ - --message-bar-bg-color:light-dark(#ffebcd, #5a3100); - --message-bar-fg-color:light-dark(#15141a, #fbfbfe); - } -} - -@supports (color: light-dark(red, red)) and (color: rgb(0 0 0 / 0)){ -:is(.dialog .mainContainer) .messageBar{ - --message-bar-border-color:light-dark( - rgb(0 0 0 / 0.08), - rgb(255 255 255 / 0.08) - ); - } -} - -@supports (color: light-dark(red, red)){ -:is(.dialog .mainContainer) .messageBar{ - --message-bar-icon-color:light-dark(#cd411e, #e49c49); - } -} - -@supports not (color: light-dark(tan, tan)){ - -:is(:is(.dialog .mainContainer) .messageBar) *{ - --csstools-light-dark-toggle--62:var(--csstools-color-scheme--light) #5a3100; - --message-bar-bg-color:var(--csstools-light-dark-toggle--62, #ffebcd); - --csstools-light-dark-toggle--63:var(--csstools-color-scheme--light) #fbfbfe; - --message-bar-fg-color:var(--csstools-light-dark-toggle--63, #15141a); - --csstools-light-dark-toggle--64:var(--csstools-color-scheme--light) rgb(255 255 255 / 0.08); - --message-bar-border-color:var(--csstools-light-dark-toggle--64, rgb(0 0 0 / 0.08)); - --csstools-light-dark-toggle--65:var(--csstools-color-scheme--light) #e49c49; - --message-bar-icon-color:var(--csstools-light-dark-toggle--65, #cd411e); +@supports (color: light-dark(red, red)) and (color: rgb(0 0 0 / 0)) { + :is(.dialog .mainContainer) .messageBar { + --message-bar-border-color: light-dark( + rgb(0 0 0 / 0.08), + rgb(255 255 255 / 0.08) + ); } } -@media screen and (forced-colors: active){ - -:is(.dialog .mainContainer) .messageBar{ - --message-bar-bg-color:HighlightText; - --message-bar-fg-color:CanvasText; - --message-bar-border-color:CanvasText; - --message-bar-icon-color:CanvasText; - } - } - -:is(.dialog .mainContainer) .messageBar{ - - align-self:stretch; - } - -:is(:is(:is(.dialog .mainContainer) .messageBar) > div)::before,:is(:is(:is(.dialog .mainContainer) .messageBar) > div) > div{ - margin-block:4px; - } - -:is(:is(:is(.dialog .mainContainer) .messageBar) > div) > div{ - display:flex; - flex-direction:column; - align-items:flex-start; - gap:8px; - flex:1 0 0; - } - -:is(:is(:is(:is(.dialog .mainContainer) .messageBar) > div) > div) .title{ - font-size:13px; - font-weight:590; - } - -:is(:is(:is(:is(.dialog .mainContainer) .messageBar) > div) > div) .description{ - font-size:13px; - } - -:is(.dialog .mainContainer) .toggler{ - display:flex; - align-items:center; - gap:8px; - align-self:stretch; - } - -:is(:is(.dialog .mainContainer) .toggler) > .togglerLabel{ - -webkit-user-select:none; - -moz-user-select:none; - user-select:none; - } - -.textLayer{ - position:absolute; - text-align:initial; - inset:0; - overflow:clip; - opacity:1; - line-height:1; - -webkit-text-size-adjust:none; - -moz-text-size-adjust:none; - text-size-adjust:none; - forced-color-adjust:none; - transform-origin:0 0; - caret-color:CanvasText; - z-index:0; -} - -.textLayer.highlighting{ - touch-action:none; - } - -.textLayer :is(span,br){ - color:transparent; - position:absolute; - white-space:pre; - cursor:text; - transform-origin:0% 0%; - } - -.textLayer > :not(.markedContent),.textLayer .markedContent span:not(.markedContent){ - z-index:1; - } - -.textLayer span.markedContent{ - top:0; - height:0; - } - -.textLayer span[role="img"]{ - -webkit-user-select:none; - -moz-user-select:none; - user-select:none; - cursor:default; - } - -.textLayer .highlight{ - --highlight-bg-color:rgb(180 0 170 / 0.25); - --highlight-selected-bg-color:rgb(0 100 0 / 0.25); - --highlight-backdrop-filter:none; - --highlight-selected-backdrop-filter:none; - } - -@media screen and (forced-colors: active){ - -.textLayer .highlight{ - --highlight-bg-color:transparent; - --highlight-selected-bg-color:transparent; - --highlight-backdrop-filter:var(--hcm-highlight-filter); - --highlight-selected-backdrop-filter:var( - --hcm-highlight-selected-filter - ); - } - } - -.textLayer .highlight{ - - margin:-1px; - padding:1px; - background-color:var(--highlight-bg-color); - -webkit-backdrop-filter:var(--highlight-backdrop-filter); - backdrop-filter:var(--highlight-backdrop-filter); - border-radius:4px; - } - -.appended:is(.textLayer .highlight){ - position:initial; - } - -.begin:is(.textLayer .highlight){ - border-radius:4px 0 0 4px; - } - -.end:is(.textLayer .highlight){ - border-radius:0 4px 4px 0; - } - -.middle:is(.textLayer .highlight){ - border-radius:0; - } - -.selected:is(.textLayer .highlight){ - background-color:var(--highlight-selected-bg-color); - -webkit-backdrop-filter:var(--highlight-selected-backdrop-filter); - backdrop-filter:var(--highlight-selected-backdrop-filter); - } - -.textLayer ::-moz-selection{ - background:rgba(0 0 255 / 0.25); - background:color-mix(in srgb, AccentColor, transparent 75%); - } - -.textLayer ::selection{ - background:rgba(0 0 255 / 0.25); - background:color-mix(in srgb, AccentColor, transparent 75%); - } - -.textLayer br::-moz-selection{ - background:transparent; - } - -.textLayer br::selection{ - background:transparent; - } - -.textLayer .endOfContent{ - display:block; - position:absolute; - inset:100% 0 0; - z-index:0; - cursor:default; - -webkit-user-select:none; - -moz-user-select:none; - user-select:none; - } - -.textLayer.selecting .endOfContent{ - top:0; - } - -.annotationLayer{ - --csstools-color-scheme--light:initial; - color-scheme:only light; - - --annotation-unfocused-field-background:url("data:image/svg+xml;charset=UTF-8,"); - --input-focus-border-color:Highlight; - --input-focus-outline:1px solid Canvas; - --input-unfocused-border-color:transparent; - --input-disabled-border-color:transparent; - --input-hover-border-color:black; - --link-outline:none; -} - -@media screen and (forced-colors: active){ - -.annotationLayer{ - --input-focus-border-color:CanvasText; - --input-unfocused-border-color:ActiveText; - --input-disabled-border-color:GrayText; - --input-hover-border-color:Highlight; - --link-outline:1.5px solid LinkText; -} - - .annotationLayer .textWidgetAnnotation :is(input,textarea):required,.annotationLayer .choiceWidgetAnnotation select:required,.annotationLayer .buttonWidgetAnnotation:is(.checkBox,.radioButton) input:required{ - outline:1.5px solid selectedItem; - } - - .annotationLayer .linkAnnotation{ - outline:var(--link-outline); - } - - :is(.annotationLayer .linkAnnotation):hover{ - -webkit-backdrop-filter:var(--hcm-highlight-filter); - backdrop-filter:var(--hcm-highlight-filter); - } - - :is(.annotationLayer .linkAnnotation) > a:hover{ - opacity:0 !important; - background:none !important; - box-shadow:none; - } - - .annotationLayer .popupAnnotation .popup{ - outline:calc(1.5px * var(--total-scale-factor)) solid CanvasText !important; - background-color:ButtonFace !important; - color:ButtonText !important; - } - - .annotationLayer .highlightArea:hover::after{ - position:absolute; - top:0; - left:0; - width:100%; - height:100%; - -webkit-backdrop-filter:var(--hcm-highlight-filter); - backdrop-filter:var(--hcm-highlight-filter); - content:""; - pointer-events:none; - } - - .annotationLayer .popupAnnotation.focused .popup{ - outline:calc(3px * var(--total-scale-factor)) solid Highlight !important; - } - } - -.annotationLayer{ - - position:absolute; - top:0; - left:0; - pointer-events:none; - transform-origin:0 0; -} - -.annotationLayer[data-main-rotation="90"] .norotate{ - transform:rotate(270deg) translateX(-100%); - } - -.annotationLayer[data-main-rotation="180"] .norotate{ - transform:rotate(180deg) translate(-100%, -100%); - } - -.annotationLayer[data-main-rotation="270"] .norotate{ - transform:rotate(90deg) translateY(-100%); - } - -.annotationLayer.disabled section,.annotationLayer.disabled .popup{ - pointer-events:none; - } - -.annotationLayer .annotationContent{ - position:absolute; - width:100%; - height:100%; - pointer-events:none; - } - -.freetext:is(.annotationLayer .annotationContent){ - background:transparent; - border:none; - inset:0; - overflow:visible; - white-space:nowrap; - font:10px sans-serif; - line-height:1.35; - } - -.annotationLayer section{ - position:absolute; - text-align:initial; - pointer-events:auto; - box-sizing:border-box; - transform-origin:0 0; - -webkit-user-select:none; - -moz-user-select:none; - user-select:none; - } - -:is(.annotationLayer section):has(div.annotationContent) canvas.annotationContent{ - display:none; - } - -:is(.annotationLayer section) .overlaidText{ - position:absolute; - top:0; - left:0; - width:0; - height:0; - display:inline-block; - overflow:hidden; - } - -.textLayer.selecting ~ .annotationLayer section{ - pointer-events:none; - } - -.annotationLayer :is(.linkAnnotation,.buttonWidgetAnnotation.pushButton) > a{ - position:absolute; - font-size:1em; - top:0; - left:0; - width:100%; - height:100%; - } - -.annotationLayer :is(.linkAnnotation,.buttonWidgetAnnotation.pushButton):not(.hasBorder) > a:hover{ - opacity:0.2; - background-color:rgb(255 255 0); - } - -.annotationLayer .linkAnnotation.hasBorder:hover{ - background-color:rgb(255 255 0 / 0.2); - } - -.annotationLayer .hasBorder{ - background-size:100% 100%; - } - -.annotationLayer .textAnnotation img{ - position:absolute; - cursor:pointer; - width:100%; - height:100%; - top:0; - left:0; - } - -.annotationLayer .textWidgetAnnotation :is(input,textarea),.annotationLayer .choiceWidgetAnnotation select,.annotationLayer .buttonWidgetAnnotation:is(.checkBox,.radioButton) input{ - background-image:var(--annotation-unfocused-field-background); - border:2px solid var(--input-unfocused-border-color); - box-sizing:border-box; - font:calc(9px * var(--total-scale-factor)) sans-serif; - height:100%; - margin:0; - vertical-align:top; - width:100%; - } - -.annotationLayer .textWidgetAnnotation :is(input,textarea):required,.annotationLayer .choiceWidgetAnnotation select:required,.annotationLayer .buttonWidgetAnnotation:is(.checkBox,.radioButton) input:required{ - outline:1.5px solid red; - } - -.annotationLayer .choiceWidgetAnnotation select option{ - padding:0; - } - -.annotationLayer .buttonWidgetAnnotation.radioButton input{ - border-radius:50%; - } - -.annotationLayer .textWidgetAnnotation textarea{ - resize:none; - } - -.annotationLayer .textWidgetAnnotation [disabled]:is(input,textarea),.annotationLayer .choiceWidgetAnnotation select[disabled],.annotationLayer .buttonWidgetAnnotation:is(.checkBox,.radioButton) input[disabled]{ - background:none; - border:2px solid var(--input-disabled-border-color); - cursor:not-allowed; - } - -.annotationLayer .textWidgetAnnotation :is(input,textarea):hover,.annotationLayer .choiceWidgetAnnotation select:hover,.annotationLayer .buttonWidgetAnnotation:is(.checkBox,.radioButton) input:hover{ - border:2px solid var(--input-hover-border-color); - } - -.annotationLayer .textWidgetAnnotation :is(input,textarea):hover,.annotationLayer .choiceWidgetAnnotation select:hover,.annotationLayer .buttonWidgetAnnotation.checkBox input:hover{ - border-radius:2px; - } - -.annotationLayer .textWidgetAnnotation :is(input,textarea):focus,.annotationLayer .choiceWidgetAnnotation select:focus{ - background:none; - border:2px solid var(--input-focus-border-color); - border-radius:2px; - outline:var(--input-focus-outline); - } - -.annotationLayer .buttonWidgetAnnotation:is(.checkBox,.radioButton) :focus{ - background-image:none; - background-color:transparent; - } - -.annotationLayer .buttonWidgetAnnotation.checkBox :focus{ - border:2px solid var(--input-focus-border-color); - border-radius:2px; - outline:var(--input-focus-outline); - } - -.annotationLayer .buttonWidgetAnnotation.radioButton :focus{ - border:2px solid var(--input-focus-border-color); - outline:var(--input-focus-outline); - } - -.annotationLayer .buttonWidgetAnnotation.checkBox input:checked::before,.annotationLayer .buttonWidgetAnnotation.checkBox input:checked::after,.annotationLayer .buttonWidgetAnnotation.radioButton input:checked::before{ - background-color:CanvasText; - content:""; - display:block; - position:absolute; - } - -.annotationLayer .buttonWidgetAnnotation.checkBox input:checked::before,.annotationLayer .buttonWidgetAnnotation.checkBox input:checked::after{ - height:80%; - left:45%; - width:1px; - } - -.annotationLayer .buttonWidgetAnnotation.checkBox input:checked::before{ - transform:rotate(45deg); - } - -.annotationLayer .buttonWidgetAnnotation.checkBox input:checked::after{ - transform:rotate(-45deg); - } - -.annotationLayer .buttonWidgetAnnotation.radioButton input:checked::before{ - border-radius:50%; - height:50%; - left:25%; - top:25%; - width:50%; - } - -.annotationLayer .textWidgetAnnotation input.comb{ - font-family:monospace; - padding-left:2px; - padding-right:0; - } - -.annotationLayer .textWidgetAnnotation input.comb:focus{ - width:103%; - } - -.annotationLayer .buttonWidgetAnnotation:is(.checkBox,.radioButton) input{ - -webkit-appearance:none; - -moz-appearance:none; - appearance:none; - } - -.annotationLayer .fileAttachmentAnnotation .popupTriggerArea{ - height:100%; - width:100%; - } - -.annotationLayer .popupAnnotation{ - position:absolute; - font-size:calc(9px * var(--total-scale-factor)); - pointer-events:none; - width:-moz-max-content; - width:max-content; - max-width:45%; - height:auto; - } - -.annotationLayer .popup{ - background-color:rgb(255 255 153); - color:black; - box-shadow:0 calc(2px * var(--total-scale-factor)) calc(5px * var(--total-scale-factor)) rgb(136 136 136); - border-radius:calc(2px * var(--total-scale-factor)); - outline:1.5px solid rgb(255 255 74); - padding:calc(6px * var(--total-scale-factor)); - cursor:pointer; - font:message-box; - white-space:normal; - word-wrap:break-word; - pointer-events:auto; - -webkit-user-select:text; - -moz-user-select:text; - user-select:text; - } - -.annotationLayer .popupAnnotation.focused .popup{ - outline-width:3px; - } - -.annotationLayer .popup *{ - font-size:calc(9px * var(--total-scale-factor)); - } - -.annotationLayer .popup > .header{ - display:inline-block; - } - -.annotationLayer .popup > .header > .title{ - display:inline; - font-weight:bold; - } - -.annotationLayer .popup > .header .popupDate{ - display:inline-block; - margin-left:calc(5px * var(--total-scale-factor)); - width:-moz-fit-content; - width:fit-content; - } - -.annotationLayer .popupContent{ - border-top:1px solid rgb(51 51 51); - margin-top:calc(2px * var(--total-scale-factor)); - padding-top:calc(2px * var(--total-scale-factor)); - } - -.annotationLayer .richText > *{ - white-space:pre-wrap; - font-size:calc(9px * var(--total-scale-factor)); - } - -.annotationLayer .popupTriggerArea{ - cursor:pointer; - } - -:is(.annotationLayer .popupTriggerArea):hover{ - -webkit-backdrop-filter:var(--hcm-highlight-filter); - backdrop-filter:var(--hcm-highlight-filter); - } - -.annotationLayer section svg{ - position:absolute; - width:100%; - height:100%; - top:0; - left:0; - } - -.annotationLayer .annotationTextContent{ - position:absolute; - width:100%; - height:100%; - opacity:0; - color:transparent; - -webkit-user-select:none; - -moz-user-select:none; - user-select:none; - pointer-events:none; - } - -:is(.annotationLayer .annotationTextContent) span{ - width:100%; - display:inline-block; - } - -.annotationLayer svg.quadrilateralsContainer{ - contain:strict; - width:0; - height:0; - position:absolute; - top:0; - left:0; - z-index:-1; - } - -:root{ - --xfa-unfocused-field-background:url("data:image/svg+xml;charset=UTF-8,"); - --xfa-focus-outline:auto; -} - -@media screen and (forced-colors: active){ - :root{ - --xfa-focus-outline:2px solid CanvasText; - } - .xfaLayer *:required{ - outline:1.5px solid selectedItem; +@supports (color: light-dark(red, red)) { + :is(.dialog .mainContainer) .messageBar { + --message-bar-icon-color: light-dark(#cd411e, #e49c49); } } -.xfaLayer{ - --csstools-color-scheme--light:initial; - color-scheme:only light; - - background-color:transparent; +@supports not (color: light-dark(tan, tan)) { + :is(:is(.dialog .mainContainer) .messageBar) * { + --csstools-light-dark-toggle--62: var(--csstools-color-scheme--light) + #5a3100; + --message-bar-bg-color: var(--csstools-light-dark-toggle--62, #ffebcd); + --csstools-light-dark-toggle--63: var(--csstools-color-scheme--light) + #fbfbfe; + --message-bar-fg-color: var(--csstools-light-dark-toggle--63, #15141a); + --csstools-light-dark-toggle--64: var(--csstools-color-scheme--light) + rgb(255 255 255 / 0.08); + --message-bar-border-color: var( + --csstools-light-dark-toggle--64, + rgb(0 0 0 / 0.08) + ); + --csstools-light-dark-toggle--65: var(--csstools-color-scheme--light) + #e49c49; + --message-bar-icon-color: var(--csstools-light-dark-toggle--65, #cd411e); + } } -.xfaLayer .highlight{ - margin:-1px; - padding:1px; - background-color:rgb(239 203 237); - border-radius:4px; +@media screen and (forced-colors: active) { + :is(.dialog .mainContainer) .messageBar { + --message-bar-bg-color: HighlightText; + --message-bar-fg-color: CanvasText; + --message-bar-border-color: CanvasText; + --message-bar-icon-color: CanvasText; + } } -.xfaLayer .highlight.appended{ - position:initial; +:is(.dialog .mainContainer) .messageBar { + align-self: stretch; } -.xfaLayer .highlight.begin{ - border-radius:4px 0 0 4px; +:is(:is(:is(.dialog .mainContainer) .messageBar) > div)::before, +:is(:is(:is(.dialog .mainContainer) .messageBar) > div) > div { + margin-block: 4px; } -.xfaLayer .highlight.end{ - border-radius:0 4px 4px 0; +:is(:is(:is(.dialog .mainContainer) .messageBar) > div) > div { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 8px; + flex: 1 0 0; } -.xfaLayer .highlight.middle{ - border-radius:0; +:is(:is(:is(:is(.dialog .mainContainer) .messageBar) > div) > div) .title { + font-size: 13px; + font-weight: 590; } -.xfaLayer .highlight.selected{ - background-color:rgb(203 223 203); +:is(:is(:is(:is(.dialog .mainContainer) .messageBar) > div) > div) + .description { + font-size: 13px; } -.xfaPage{ - overflow:hidden; - position:relative; +:is(.dialog .mainContainer) .toggler { + display: flex; + align-items: center; + gap: 8px; + align-self: stretch; } -.xfaContentarea{ - position:absolute; +:is(:is(.dialog .mainContainer) .toggler) > .togglerLabel { + -webkit-user-select: none; + -moz-user-select: none; + user-select: none; } -.xfaPrintOnly{ - display:none; +.textLayer { + position: absolute; + text-align: initial; + inset: 0; + overflow: clip; + opacity: 1; + line-height: 1; + -webkit-text-size-adjust: none; + -moz-text-size-adjust: none; + text-size-adjust: none; + forced-color-adjust: none; + transform-origin: 0 0; + caret-color: CanvasText; + z-index: 0; } -.xfaLayer{ - position:absolute; - text-align:initial; - top:0; - left:0; - transform-origin:0 0; - line-height:1.2; +.textLayer.highlighting { + touch-action: none; } -.xfaLayer *{ - color:inherit; - font:inherit; - font-style:inherit; - font-weight:inherit; - font-kerning:inherit; - letter-spacing:-0.01px; - text-align:inherit; - text-decoration:inherit; - box-sizing:border-box; - background-color:transparent; - padding:0; - margin:0; - pointer-events:auto; - line-height:inherit; +.textLayer :is(span, br) { + color: transparent; + position: absolute; + white-space: pre; + cursor: text; + transform-origin: 0% 0%; } -.xfaLayer *:required{ - outline:1.5px solid red; +.textLayer > :not(.markedContent), +.textLayer .markedContent span:not(.markedContent) { + z-index: 1; +} + +.textLayer span.markedContent { + top: 0; + height: 0; +} + +.textLayer span[role='img'] { + -webkit-user-select: none; + -moz-user-select: none; + user-select: none; + cursor: default; +} + +.textLayer .highlight { + --highlight-bg-color: rgb(180 0 170 / 0.25); + --highlight-selected-bg-color: rgb(0 100 0 / 0.25); + --highlight-backdrop-filter: none; + --highlight-selected-backdrop-filter: none; +} + +@media screen and (forced-colors: active) { + .textLayer .highlight { + --highlight-bg-color: transparent; + --highlight-selected-bg-color: transparent; + --highlight-backdrop-filter: var(--hcm-highlight-filter); + --highlight-selected-backdrop-filter: var(--hcm-highlight-selected-filter); + } +} + +.textLayer .highlight { + margin: -1px; + padding: 1px; + background-color: var(--highlight-bg-color); + -webkit-backdrop-filter: var(--highlight-backdrop-filter); + backdrop-filter: var(--highlight-backdrop-filter); + border-radius: 4px; +} + +.appended:is(.textLayer .highlight) { + position: initial; +} + +.begin:is(.textLayer .highlight) { + border-radius: 4px 0 0 4px; +} + +.end:is(.textLayer .highlight) { + border-radius: 0 4px 4px 0; +} + +.middle:is(.textLayer .highlight) { + border-radius: 0; +} + +.selected:is(.textLayer .highlight) { + background-color: var(--highlight-selected-bg-color); + -webkit-backdrop-filter: var(--highlight-selected-backdrop-filter); + backdrop-filter: var(--highlight-selected-backdrop-filter); +} + +.textLayer ::-moz-selection { + background: rgba(0 0 255 / 0.25); + background: color-mix(in srgb, AccentColor, transparent 75%); +} + +.textLayer ::selection { + background: rgba(0 0 255 / 0.25); + background: color-mix(in srgb, AccentColor, transparent 75%); +} + +.textLayer br::-moz-selection { + background: transparent; +} + +.textLayer br::selection { + background: transparent; +} + +.textLayer .endOfContent { + display: block; + position: absolute; + inset: 100% 0 0; + z-index: 0; + cursor: default; + -webkit-user-select: none; + -moz-user-select: none; + user-select: none; +} + +.textLayer.selecting .endOfContent { + top: 0; +} + +.annotationLayer { + --csstools-color-scheme--light: initial; + color-scheme: only light; + + --annotation-unfocused-field-background: url("data:image/svg+xml;charset=UTF-8,"); + --input-focus-border-color: Highlight; + --input-focus-outline: 1px solid Canvas; + --input-unfocused-border-color: transparent; + --input-disabled-border-color: transparent; + --input-hover-border-color: black; + --link-outline: none; +} + +@media screen and (forced-colors: active) { + .annotationLayer { + --input-focus-border-color: CanvasText; + --input-unfocused-border-color: ActiveText; + --input-disabled-border-color: GrayText; + --input-hover-border-color: Highlight; + --link-outline: 1.5px solid LinkText; + } + + .annotationLayer .textWidgetAnnotation :is(input, textarea):required, + .annotationLayer .choiceWidgetAnnotation select:required, + .annotationLayer + .buttonWidgetAnnotation:is(.checkBox, .radioButton) + input:required { + outline: 1.5px solid selectedItem; + } + + .annotationLayer .linkAnnotation { + outline: var(--link-outline); + } + + :is(.annotationLayer .linkAnnotation):hover { + -webkit-backdrop-filter: var(--hcm-highlight-filter); + backdrop-filter: var(--hcm-highlight-filter); + } + + :is(.annotationLayer .linkAnnotation) > a:hover { + opacity: 0 !important; + background: none !important; + box-shadow: none; + } + + .annotationLayer .popupAnnotation .popup { + outline: calc(1.5px * var(--total-scale-factor)) solid CanvasText !important; + background-color: ButtonFace !important; + color: ButtonText !important; + } + + .annotationLayer .highlightArea:hover::after { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + -webkit-backdrop-filter: var(--hcm-highlight-filter); + backdrop-filter: var(--hcm-highlight-filter); + content: ''; + pointer-events: none; + } + + .annotationLayer .popupAnnotation.focused .popup { + outline: calc(3px * var(--total-scale-factor)) solid Highlight !important; + } +} + +.annotationLayer { + position: absolute; + top: 0; + left: 0; + pointer-events: none; + transform-origin: 0 0; +} + +.annotationLayer[data-main-rotation='90'] .norotate { + transform: rotate(270deg) translateX(-100%); +} + +.annotationLayer[data-main-rotation='180'] .norotate { + transform: rotate(180deg) translate(-100%, -100%); +} + +.annotationLayer[data-main-rotation='270'] .norotate { + transform: rotate(90deg) translateY(-100%); +} + +.annotationLayer.disabled section, +.annotationLayer.disabled .popup { + pointer-events: none; +} + +.annotationLayer .annotationContent { + position: absolute; + width: 100%; + height: 100%; + pointer-events: none; +} + +.freetext:is(.annotationLayer .annotationContent) { + background: transparent; + border: none; + inset: 0; + overflow: visible; + white-space: nowrap; + font: 10px sans-serif; + line-height: 1.35; +} + +.annotationLayer section { + position: absolute; + text-align: initial; + pointer-events: auto; + box-sizing: border-box; + transform-origin: 0 0; + -webkit-user-select: none; + -moz-user-select: none; + user-select: none; +} + +:is(.annotationLayer section):has(div.annotationContent) + canvas.annotationContent { + display: none; +} + +:is(.annotationLayer section) .overlaidText { + position: absolute; + top: 0; + left: 0; + width: 0; + height: 0; + display: inline-block; + overflow: hidden; +} + +.textLayer.selecting ~ .annotationLayer section { + pointer-events: none; +} + +.annotationLayer :is(.linkAnnotation, .buttonWidgetAnnotation.pushButton) > a { + position: absolute; + font-size: 1em; + top: 0; + left: 0; + width: 100%; + height: 100%; +} + +.annotationLayer + :is(.linkAnnotation, .buttonWidgetAnnotation.pushButton):not(.hasBorder) + > a:hover { + opacity: 0.2; + background-color: rgb(255 255 0); +} + +.annotationLayer .linkAnnotation.hasBorder:hover { + background-color: rgb(255 255 0 / 0.2); +} + +.annotationLayer .hasBorder { + background-size: 100% 100%; +} + +.annotationLayer .textAnnotation img { + position: absolute; + cursor: pointer; + width: 100%; + height: 100%; + top: 0; + left: 0; +} + +.annotationLayer .textWidgetAnnotation :is(input, textarea), +.annotationLayer .choiceWidgetAnnotation select, +.annotationLayer .buttonWidgetAnnotation:is(.checkBox, .radioButton) input { + background-image: var(--annotation-unfocused-field-background); + border: 2px solid var(--input-unfocused-border-color); + box-sizing: border-box; + font: calc(9px * var(--total-scale-factor)) sans-serif; + height: 100%; + margin: 0; + vertical-align: top; + width: 100%; +} + +.annotationLayer .textWidgetAnnotation :is(input, textarea):required, +.annotationLayer .choiceWidgetAnnotation select:required, +.annotationLayer + .buttonWidgetAnnotation:is(.checkBox, .radioButton) + input:required { + outline: 1.5px solid red; +} + +.annotationLayer .choiceWidgetAnnotation select option { + padding: 0; +} + +.annotationLayer .buttonWidgetAnnotation.radioButton input { + border-radius: 50%; +} + +.annotationLayer .textWidgetAnnotation textarea { + resize: none; +} + +.annotationLayer .textWidgetAnnotation [disabled]:is(input, textarea), +.annotationLayer .choiceWidgetAnnotation select[disabled], +.annotationLayer + .buttonWidgetAnnotation:is(.checkBox, .radioButton) + input[disabled] { + background: none; + border: 2px solid var(--input-disabled-border-color); + cursor: not-allowed; +} + +.annotationLayer .textWidgetAnnotation :is(input, textarea):hover, +.annotationLayer .choiceWidgetAnnotation select:hover, +.annotationLayer + .buttonWidgetAnnotation:is(.checkBox, .radioButton) + input:hover { + border: 2px solid var(--input-hover-border-color); +} + +.annotationLayer .textWidgetAnnotation :is(input, textarea):hover, +.annotationLayer .choiceWidgetAnnotation select:hover, +.annotationLayer .buttonWidgetAnnotation.checkBox input:hover { + border-radius: 2px; +} + +.annotationLayer .textWidgetAnnotation :is(input, textarea):focus, +.annotationLayer .choiceWidgetAnnotation select:focus { + background: none; + border: 2px solid var(--input-focus-border-color); + border-radius: 2px; + outline: var(--input-focus-outline); +} + +.annotationLayer .buttonWidgetAnnotation:is(.checkBox, .radioButton) :focus { + background-image: none; + background-color: transparent; +} + +.annotationLayer .buttonWidgetAnnotation.checkBox :focus { + border: 2px solid var(--input-focus-border-color); + border-radius: 2px; + outline: var(--input-focus-outline); +} + +.annotationLayer .buttonWidgetAnnotation.radioButton :focus { + border: 2px solid var(--input-focus-border-color); + outline: var(--input-focus-outline); +} + +.annotationLayer .buttonWidgetAnnotation.checkBox input:checked::before, +.annotationLayer .buttonWidgetAnnotation.checkBox input:checked::after, +.annotationLayer .buttonWidgetAnnotation.radioButton input:checked::before { + background-color: CanvasText; + content: ''; + display: block; + position: absolute; +} + +.annotationLayer .buttonWidgetAnnotation.checkBox input:checked::before, +.annotationLayer .buttonWidgetAnnotation.checkBox input:checked::after { + height: 80%; + left: 45%; + width: 1px; +} + +.annotationLayer .buttonWidgetAnnotation.checkBox input:checked::before { + transform: rotate(45deg); +} + +.annotationLayer .buttonWidgetAnnotation.checkBox input:checked::after { + transform: rotate(-45deg); +} + +.annotationLayer .buttonWidgetAnnotation.radioButton input:checked::before { + border-radius: 50%; + height: 50%; + left: 25%; + top: 25%; + width: 50%; +} + +.annotationLayer .textWidgetAnnotation input.comb { + font-family: monospace; + padding-left: 2px; + padding-right: 0; +} + +.annotationLayer .textWidgetAnnotation input.comb:focus { + width: 103%; +} + +.annotationLayer .buttonWidgetAnnotation:is(.checkBox, .radioButton) input { + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; +} + +.annotationLayer .fileAttachmentAnnotation .popupTriggerArea { + height: 100%; + width: 100%; +} + +.annotationLayer .popupAnnotation { + position: absolute; + font-size: calc(9px * var(--total-scale-factor)); + pointer-events: none; + width: -moz-max-content; + width: max-content; + max-width: 45%; + height: auto; +} + +.annotationLayer .popup { + background-color: rgb(255 255 153); + color: black; + box-shadow: 0 calc(2px * var(--total-scale-factor)) + calc(5px * var(--total-scale-factor)) rgb(136 136 136); + border-radius: calc(2px * var(--total-scale-factor)); + outline: 1.5px solid rgb(255 255 74); + padding: calc(6px * var(--total-scale-factor)); + cursor: pointer; + font: message-box; + white-space: normal; + word-wrap: break-word; + pointer-events: auto; + -webkit-user-select: text; + -moz-user-select: text; + user-select: text; +} + +.annotationLayer .popupAnnotation.focused .popup { + outline-width: 3px; +} + +.annotationLayer .popup * { + font-size: calc(9px * var(--total-scale-factor)); +} + +.annotationLayer .popup > .header { + display: inline-block; +} + +.annotationLayer .popup > .header > .title { + display: inline; + font-weight: bold; +} + +.annotationLayer .popup > .header .popupDate { + display: inline-block; + margin-left: calc(5px * var(--total-scale-factor)); + width: -moz-fit-content; + width: fit-content; +} + +.annotationLayer .popupContent { + border-top: 1px solid rgb(51 51 51); + margin-top: calc(2px * var(--total-scale-factor)); + padding-top: calc(2px * var(--total-scale-factor)); +} + +.annotationLayer .richText > * { + white-space: pre-wrap; + font-size: calc(9px * var(--total-scale-factor)); +} + +.annotationLayer .popupTriggerArea { + cursor: pointer; +} + +:is(.annotationLayer .popupTriggerArea):hover { + -webkit-backdrop-filter: var(--hcm-highlight-filter); + backdrop-filter: var(--hcm-highlight-filter); +} + +.annotationLayer section svg { + position: absolute; + width: 100%; + height: 100%; + top: 0; + left: 0; +} + +.annotationLayer .annotationTextContent { + position: absolute; + width: 100%; + height: 100%; + opacity: 0; + color: transparent; + -webkit-user-select: none; + -moz-user-select: none; + user-select: none; + pointer-events: none; +} + +:is(.annotationLayer .annotationTextContent) span { + width: 100%; + display: inline-block; +} + +.annotationLayer svg.quadrilateralsContainer { + contain: strict; + width: 0; + height: 0; + position: absolute; + top: 0; + left: 0; + z-index: -1; +} + +:root { + --xfa-unfocused-field-background: url("data:image/svg+xml;charset=UTF-8,"); + --xfa-focus-outline: auto; +} + +@media screen and (forced-colors: active) { + :root { + --xfa-focus-outline: 2px solid CanvasText; + } + .xfaLayer *:required { + outline: 1.5px solid selectedItem; + } +} + +.xfaLayer { + --csstools-color-scheme--light: initial; + color-scheme: only light; + + background-color: transparent; +} + +.xfaLayer .highlight { + margin: -1px; + padding: 1px; + background-color: rgb(239 203 237); + border-radius: 4px; +} + +.xfaLayer .highlight.appended { + position: initial; +} + +.xfaLayer .highlight.begin { + border-radius: 4px 0 0 4px; +} + +.xfaLayer .highlight.end { + border-radius: 0 4px 4px 0; +} + +.xfaLayer .highlight.middle { + border-radius: 0; +} + +.xfaLayer .highlight.selected { + background-color: rgb(203 223 203); +} + +.xfaPage { + overflow: hidden; + position: relative; +} + +.xfaContentarea { + position: absolute; +} + +.xfaPrintOnly { + display: none; +} + +.xfaLayer { + position: absolute; + text-align: initial; + top: 0; + left: 0; + transform-origin: 0 0; + line-height: 1.2; +} + +.xfaLayer * { + color: inherit; + font: inherit; + font-style: inherit; + font-weight: inherit; + font-kerning: inherit; + letter-spacing: -0.01px; + text-align: inherit; + text-decoration: inherit; + box-sizing: border-box; + background-color: transparent; + padding: 0; + margin: 0; + pointer-events: auto; + line-height: inherit; +} + +.xfaLayer *:required { + outline: 1.5px solid red; } .xfaLayer div, .xfaLayer svg, -.xfaLayer svg *{ - pointer-events:none; +.xfaLayer svg * { + pointer-events: none; } -.xfaLayer a{ - color:blue; +.xfaLayer a { + color: blue; } -.xfaRich li{ - margin-left:3em; +.xfaRich li { + margin-left: 3em; } -.xfaFont{ - color:black; - font-weight:normal; - font-kerning:none; - font-size:10px; - font-style:normal; - letter-spacing:0; - text-decoration:none; - vertical-align:0; +.xfaFont { + color: black; + font-weight: normal; + font-kerning: none; + font-size: 10px; + font-style: normal; + letter-spacing: 0; + text-decoration: none; + vertical-align: 0; } -.xfaCaption{ - overflow:hidden; - flex:0 0 auto; +.xfaCaption { + overflow: hidden; + flex: 0 0 auto; } -.xfaCaptionForCheckButton{ - overflow:hidden; - flex:1 1 auto; +.xfaCaptionForCheckButton { + overflow: hidden; + flex: 1 1 auto; } -.xfaLabel{ - height:100%; - width:100%; +.xfaLabel { + height: 100%; + width: 100%; } -.xfaLeft{ - display:flex; - flex-direction:row; - align-items:center; +.xfaLeft { + display: flex; + flex-direction: row; + align-items: center; } -.xfaRight{ - display:flex; - flex-direction:row-reverse; - align-items:center; +.xfaRight { + display: flex; + flex-direction: row-reverse; + align-items: center; } -:is(.xfaLeft, .xfaRight) > :is(.xfaCaption, .xfaCaptionForCheckButton){ - max-height:100%; +:is(.xfaLeft, .xfaRight) > :is(.xfaCaption, .xfaCaptionForCheckButton) { + max-height: 100%; } -.xfaTop{ - display:flex; - flex-direction:column; - align-items:flex-start; +.xfaTop { + display: flex; + flex-direction: column; + align-items: flex-start; } -.xfaBottom{ - display:flex; - flex-direction:column-reverse; - align-items:flex-start; +.xfaBottom { + display: flex; + flex-direction: column-reverse; + align-items: flex-start; } -:is(.xfaTop, .xfaBottom) > :is(.xfaCaption, .xfaCaptionForCheckButton){ - width:100%; +:is(.xfaTop, .xfaBottom) > :is(.xfaCaption, .xfaCaptionForCheckButton) { + width: 100%; } -.xfaBorder{ - background-color:transparent; - position:absolute; - pointer-events:none; +.xfaBorder { + background-color: transparent; + position: absolute; + pointer-events: none; } -.xfaWrapped{ - width:100%; - height:100%; +.xfaWrapped { + width: 100%; + height: 100%; } -:is(.xfaTextfield, .xfaSelect):focus{ - background-image:none; - background-color:transparent; - outline:var(--xfa-focus-outline); - outline-offset:-1px; +:is(.xfaTextfield, .xfaSelect):focus { + background-image: none; + background-color: transparent; + outline: var(--xfa-focus-outline); + outline-offset: -1px; } -:is(.xfaCheckbox, .xfaRadio):focus{ - outline:var(--xfa-focus-outline); +:is(.xfaCheckbox, .xfaRadio):focus { + outline: var(--xfa-focus-outline); } .xfaTextfield, -.xfaSelect{ - height:100%; - width:100%; - flex:1 1 auto; - border:none; - resize:none; - background-image:var(--xfa-unfocused-field-background); +.xfaSelect { + height: 100%; + width: 100%; + flex: 1 1 auto; + border: none; + resize: none; + background-image: var(--xfa-unfocused-field-background); } -.xfaSelect{ - padding-inline:2px; +.xfaSelect { + padding-inline: 2px; } -:is(.xfaTop, .xfaBottom) > :is(.xfaTextfield, .xfaSelect){ - flex:0 1 auto; +:is(.xfaTop, .xfaBottom) > :is(.xfaTextfield, .xfaSelect) { + flex: 0 1 auto; } -.xfaButton{ - cursor:pointer; - width:100%; - height:100%; - border:none; - text-align:center; +.xfaButton { + cursor: pointer; + width: 100%; + height: 100%; + border: none; + text-align: center; } -.xfaLink{ - width:100%; - height:100%; - position:absolute; - top:0; - left:0; +.xfaLink { + width: 100%; + height: 100%; + position: absolute; + top: 0; + left: 0; } .xfaCheckbox, -.xfaRadio{ - width:100%; - height:100%; - flex:0 0 auto; - border:none; +.xfaRadio { + width: 100%; + height: 100%; + flex: 0 0 auto; + border: none; } -.xfaRich{ - white-space:pre-wrap; - width:100%; - height:100%; +.xfaRich { + white-space: pre-wrap; + width: 100%; + height: 100%; } -.xfaImage{ - -o-object-position:left top; - object-position:left top; - -o-object-fit:contain; - object-fit:contain; - width:100%; - height:100%; +.xfaImage { + -o-object-position: left top; + object-position: left top; + -o-object-fit: contain; + object-fit: contain; + width: 100%; + height: 100%; } .xfaLrTb, .xfaRlTb, -.xfaTb{ - display:flex; - flex-direction:column; - align-items:stretch; +.xfaTb { + display: flex; + flex-direction: column; + align-items: stretch; } -.xfaLr{ - display:flex; - flex-direction:row; - align-items:stretch; +.xfaLr { + display: flex; + flex-direction: row; + align-items: stretch; } -.xfaRl{ - display:flex; - flex-direction:row-reverse; - align-items:stretch; +.xfaRl { + display: flex; + flex-direction: row-reverse; + align-items: stretch; } -.xfaTb > div{ - justify-content:left; +.xfaTb > div { + justify-content: left; } -.xfaPosition{ - position:relative; +.xfaPosition { + position: relative; } -.xfaArea{ - position:relative; +.xfaArea { + position: relative; } -.xfaValignMiddle{ - display:flex; - align-items:center; +.xfaValignMiddle { + display: flex; + align-items: center; } -.xfaTable{ - display:flex; - flex-direction:column; - align-items:stretch; +.xfaTable { + display: flex; + flex-direction: column; + align-items: stretch; } -.xfaTable .xfaRow{ - display:flex; - flex-direction:row; - align-items:stretch; +.xfaTable .xfaRow { + display: flex; + flex-direction: row; + align-items: stretch; } -.xfaTable .xfaRlRow{ - display:flex; - flex-direction:row-reverse; - align-items:stretch; - flex:1; +.xfaTable .xfaRlRow { + display: flex; + flex-direction: row-reverse; + align-items: stretch; + flex: 1; } -.xfaTable .xfaRlRow > div{ - flex:1; +.xfaTable .xfaRlRow > div { + flex: 1; } -:is(.xfaNonInteractive, .xfaDisabled, .xfaReadOnly) :is(input, textarea){ - background:initial; +:is(.xfaNonInteractive, .xfaDisabled, .xfaReadOnly) :is(input, textarea) { + background: initial; } -@media print{ +@media print { .xfaTextfield, - .xfaSelect{ - background:transparent; + .xfaSelect { + background: transparent; } - .xfaSelect{ - -webkit-appearance:none; - -moz-appearance:none; - appearance:none; - text-indent:1px; - text-overflow:""; + .xfaSelect { + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + text-indent: 1px; + text-overflow: ''; } } -.canvasWrapper svg{ - transform:none; +.canvasWrapper svg { + transform: none; +} + +.moving:is(.canvasWrapper svg) { + z-index: 100000; +} + +[data-main-rotation='90']:is( + .highlight:is(.canvasWrapper svg), + .highlightOutline:is(.canvasWrapper svg) + ) + mask, +[data-main-rotation='90']:is( + .highlight:is(.canvasWrapper svg), + .highlightOutline:is(.canvasWrapper svg) + ) + use:not(.clip, .mask) { + transform: matrix(0, 1, -1, 0, 1, 0); +} + +[data-main-rotation='180']:is( + .highlight:is(.canvasWrapper svg), + .highlightOutline:is(.canvasWrapper svg) + ) + mask, +[data-main-rotation='180']:is( + .highlight:is(.canvasWrapper svg), + .highlightOutline:is(.canvasWrapper svg) + ) + use:not(.clip, .mask) { + transform: matrix(-1, 0, 0, -1, 1, 1); +} + +[data-main-rotation='270']:is( + .highlight:is(.canvasWrapper svg), + .highlightOutline:is(.canvasWrapper svg) + ) + mask, +[data-main-rotation='270']:is( + .highlight:is(.canvasWrapper svg), + .highlightOutline:is(.canvasWrapper svg) + ) + use:not(.clip, .mask) { + transform: matrix(0, -1, 1, 0, 0, 1); +} + +.draw:is(.canvasWrapper svg) { + position: absolute; + mix-blend-mode: normal; +} + +.draw[data-draw-rotation='90']:is(.canvasWrapper svg) { + transform: rotate(90deg); +} + +.draw[data-draw-rotation='180']:is(.canvasWrapper svg) { + transform: rotate(180deg); +} + +.draw[data-draw-rotation='270']:is(.canvasWrapper svg) { + transform: rotate(270deg); +} + +.highlight:is(.canvasWrapper svg) { + --blend-mode: multiply; +} + +@media screen and (forced-colors: active) { + .highlight:is(.canvasWrapper svg) { + --blend-mode: difference; } +} -.moving:is(.canvasWrapper svg){ - z-index:100000; - } +.highlight:is(.canvasWrapper svg) { + position: absolute; + mix-blend-mode: var(--blend-mode); +} -[data-main-rotation="90"]:is(.highlight:is(.canvasWrapper svg),.highlightOutline:is(.canvasWrapper svg)) mask,[data-main-rotation="90"]:is(.highlight:is(.canvasWrapper svg),.highlightOutline:is(.canvasWrapper svg)) use:not(.clip,.mask){ - transform:matrix(0, 1, -1, 0, 1, 0); - } +.highlight:is(.canvasWrapper svg):not(.free) { + fill-rule: evenodd; +} -[data-main-rotation="180"]:is(.highlight:is(.canvasWrapper svg),.highlightOutline:is(.canvasWrapper svg)) mask,[data-main-rotation="180"]:is(.highlight:is(.canvasWrapper svg),.highlightOutline:is(.canvasWrapper svg)) use:not(.clip,.mask){ - transform:matrix(-1, 0, 0, -1, 1, 1); - } +.highlightOutline:is(.canvasWrapper svg) { + position: absolute; + mix-blend-mode: normal; + fill-rule: evenodd; + fill: none; +} -[data-main-rotation="270"]:is(.highlight:is(.canvasWrapper svg),.highlightOutline:is(.canvasWrapper svg)) mask,[data-main-rotation="270"]:is(.highlight:is(.canvasWrapper svg),.highlightOutline:is(.canvasWrapper svg)) use:not(.clip,.mask){ - transform:matrix(0, -1, 1, 0, 0, 1); - } +.highlightOutline.hovered:is(.canvasWrapper svg):not(.free):not(.selected) { + stroke: var(--hover-outline-color); + stroke-width: var(--outline-width); +} -.draw:is(.canvasWrapper svg){ - position:absolute; - mix-blend-mode:normal; - } +.highlightOutline.selected:is(.canvasWrapper svg):not(.free) .mainOutline { + stroke: var(--outline-around-color); + stroke-width: calc(var(--outline-width) + 2 * var(--outline-around-width)); +} -.draw[data-draw-rotation="90"]:is(.canvasWrapper svg){ - transform:rotate(90deg); - } +.highlightOutline.selected:is(.canvasWrapper svg):not(.free) .secondaryOutline { + stroke: var(--outline-color); + stroke-width: var(--outline-width); +} -.draw[data-draw-rotation="180"]:is(.canvasWrapper svg){ - transform:rotate(180deg); - } +.highlightOutline.free.hovered:is(.canvasWrapper svg):not(.selected) { + stroke: var(--hover-outline-color); + stroke-width: calc(2 * var(--outline-width)); +} -.draw[data-draw-rotation="270"]:is(.canvasWrapper svg){ - transform:rotate(270deg); - } +.highlightOutline.free.selected:is(.canvasWrapper svg) .mainOutline { + stroke: var(--outline-around-color); + stroke-width: calc(2 * (var(--outline-width) + var(--outline-around-width))); +} -.highlight:is(.canvasWrapper svg){ - --blend-mode:multiply; - } +.highlightOutline.free.selected:is(.canvasWrapper svg) .secondaryOutline { + stroke: var(--outline-color); + stroke-width: calc(2 * var(--outline-width)); +} -@media screen and (forced-colors: active){ - -.highlight:is(.canvasWrapper svg){ - --blend-mode:difference; - } - } - -.highlight:is(.canvasWrapper svg){ - - position:absolute; - mix-blend-mode:var(--blend-mode); - } - -.highlight:is(.canvasWrapper svg):not(.free){ - fill-rule:evenodd; - } - -.highlightOutline:is(.canvasWrapper svg){ - position:absolute; - mix-blend-mode:normal; - fill-rule:evenodd; - fill:none; - } - -.highlightOutline.hovered:is(.canvasWrapper svg):not(.free):not(.selected){ - stroke:var(--hover-outline-color); - stroke-width:var(--outline-width); - } - -.highlightOutline.selected:is(.canvasWrapper svg):not(.free) .mainOutline{ - stroke:var(--outline-around-color); - stroke-width:calc( - var(--outline-width) + 2 * var(--outline-around-width) - ); - } - -.highlightOutline.selected:is(.canvasWrapper svg):not(.free) .secondaryOutline{ - stroke:var(--outline-color); - stroke-width:var(--outline-width); - } - -.highlightOutline.free.hovered:is(.canvasWrapper svg):not(.selected){ - stroke:var(--hover-outline-color); - stroke-width:calc(2 * var(--outline-width)); - } - -.highlightOutline.free.selected:is(.canvasWrapper svg) .mainOutline{ - stroke:var(--outline-around-color); - stroke-width:calc( - 2 * (var(--outline-width) + var(--outline-around-width)) - ); - } - -.highlightOutline.free.selected:is(.canvasWrapper svg) .secondaryOutline{ - stroke:var(--outline-color); - stroke-width:calc(2 * var(--outline-width)); - } - -.toggle-button{ - --button-background-color:color-mix(in srgb, currentColor 7%, transparent); - --button-background-color-hover:color-mix( +.toggle-button { + --button-background-color: color-mix(in srgb, currentColor 7%, transparent); + --button-background-color-hover: color-mix( in srgb, currentColor 14%, transparent ); - --button-background-color-active:color-mix( + --button-background-color-active: color-mix( in srgb, currentColor 21%, transparent ); - --csstools-light-dark-toggle--66:var(--csstools-color-scheme--light) #0df; - --color-accent-primary:var(--csstools-light-dark-toggle--66, #0060df); - --csstools-light-dark-toggle--67:var(--csstools-color-scheme--light) #80ebff; - --color-accent-primary-hover:var(--csstools-light-dark-toggle--67, #0250bb); - --csstools-light-dark-toggle--68:var(--csstools-color-scheme--light) #aaf2ff; - --color-accent-primary-active:var(--csstools-light-dark-toggle--68, #054096); - --border-radius-circle:9999px; - --border-width:1px; - --size-item-small:16px; - --size-item-large:32px; - --csstools-light-dark-toggle--69:var(--csstools-color-scheme--light) #1c1b22; - --color-canvas:var(--csstools-light-dark-toggle--69, white); - --background-color-canvas:var(--color-canvas); - --csstools-light-dark-toggle--70:var(--csstools-color-scheme--light) #f9f9fa; - --border-color-interactive:var(--csstools-light-dark-toggle--70, #8f8f9d); - --border-color-interactive-hover:var(--border-color-interactive); - --border-color-interactive-active:var(--border-color-interactive); + --csstools-light-dark-toggle--66: var(--csstools-color-scheme--light) #0df; + --color-accent-primary: var(--csstools-light-dark-toggle--66, #0060df); + --csstools-light-dark-toggle--67: var(--csstools-color-scheme--light) #80ebff; + --color-accent-primary-hover: var(--csstools-light-dark-toggle--67, #0250bb); + --csstools-light-dark-toggle--68: var(--csstools-color-scheme--light) #aaf2ff; + --color-accent-primary-active: var(--csstools-light-dark-toggle--68, #054096); + --border-radius-circle: 9999px; + --border-width: 1px; + --size-item-small: 16px; + --size-item-large: 32px; + --csstools-light-dark-toggle--69: var(--csstools-color-scheme--light) #1c1b22; + --color-canvas: var(--csstools-light-dark-toggle--69, white); + --background-color-canvas: var(--color-canvas); + --csstools-light-dark-toggle--70: var(--csstools-color-scheme--light) #f9f9fa; + --border-color-interactive: var(--csstools-light-dark-toggle--70, #8f8f9d); + --border-color-interactive-hover: var(--border-color-interactive); + --border-color-interactive-active: var(--border-color-interactive); } -@supports (color: light-dark(red, red)){ -.toggle-button{ - --color-accent-primary:light-dark(#0060df, #0df); - --color-accent-primary-hover:light-dark(#0250bb, #80ebff); - --color-accent-primary-active:light-dark(#054096, #aaf2ff); - --color-canvas:light-dark(white, #1c1b22); - --border-color-interactive:light-dark(#8f8f9d, #f9f9fa); -} -} - -@supports not (color: light-dark(tan, tan)){ - -.toggle-button *{ - --csstools-light-dark-toggle--66:var(--csstools-color-scheme--light) #0df; - --color-accent-primary:var(--csstools-light-dark-toggle--66, #0060df); - --csstools-light-dark-toggle--67:var(--csstools-color-scheme--light) #80ebff; - --color-accent-primary-hover:var(--csstools-light-dark-toggle--67, #0250bb); - --csstools-light-dark-toggle--68:var(--csstools-color-scheme--light) #aaf2ff; - --color-accent-primary-active:var(--csstools-light-dark-toggle--68, #054096); - --csstools-light-dark-toggle--69:var(--csstools-color-scheme--light) #1c1b22; - --color-canvas:var(--csstools-light-dark-toggle--69, white); - --csstools-light-dark-toggle--70:var(--csstools-color-scheme--light) #f9f9fa; - --border-color-interactive:var(--csstools-light-dark-toggle--70, #8f8f9d); +@supports (color: light-dark(red, red)) { + .toggle-button { + --color-accent-primary: light-dark(#0060df, #0df); + --color-accent-primary-hover: light-dark(#0250bb, #80ebff); + --color-accent-primary-active: light-dark(#054096, #aaf2ff); + --color-canvas: light-dark(white, #1c1b22); + --border-color-interactive: light-dark(#8f8f9d, #f9f9fa); } } -@media (forced-colors: active){ - -.toggle-button{ - --color-accent-primary:ButtonText; - --color-accent-primary-hover:SelectedItem; - --color-accent-primary-active:SelectedItem; - --button-background-color:ButtonFace; - --border-color-interactive:ButtonText; - --border-color-interactive-hover:SelectedItem; - --border-color-interactive-active:ButtonText; - --color-canvas:ButtonText; - --background-color-canvas:Canvas; -} +@supports not (color: light-dark(tan, tan)) { + .toggle-button * { + --csstools-light-dark-toggle--66: var(--csstools-color-scheme--light) #0df; + --color-accent-primary: var(--csstools-light-dark-toggle--66, #0060df); + --csstools-light-dark-toggle--67: var(--csstools-color-scheme--light) + #80ebff; + --color-accent-primary-hover: var( + --csstools-light-dark-toggle--67, + #0250bb + ); + --csstools-light-dark-toggle--68: var(--csstools-color-scheme--light) + #aaf2ff; + --color-accent-primary-active: var( + --csstools-light-dark-toggle--68, + #054096 + ); + --csstools-light-dark-toggle--69: var(--csstools-color-scheme--light) + #1c1b22; + --color-canvas: var(--csstools-light-dark-toggle--69, white); + --csstools-light-dark-toggle--70: var(--csstools-color-scheme--light) + #f9f9fa; + --border-color-interactive: var(--csstools-light-dark-toggle--70, #8f8f9d); } +} -.toggle-button{ - --toggle-background-color:var(--button-background-color); - --toggle-background-color-hover:var(--button-background-color-hover); - --toggle-background-color-active:var(--button-background-color-active); - --toggle-background-color-pressed:var(--color-accent-primary); - --toggle-background-color-pressed-hover:var(--color-accent-primary-hover); - --toggle-background-color-pressed-active:var(--color-accent-primary-active); - --toggle-border-color:var(--border-color-interactive); - --toggle-border-color-hover:var(--toggle-border-color); - --toggle-border-color-active:var(--toggle-border-color); - --toggle-border-radius:var(--border-radius-circle); - --toggle-border-width:var(--border-width); - --toggle-height:var(--size-item-small); - --toggle-width:var(--size-item-large); - --toggle-dot-background-color:var(--toggle-border-color); - --toggle-dot-background-color-hover:var(--toggle-dot-background-color); - --toggle-dot-background-color-active:var(--toggle-dot-background-color); - --toggle-dot-background-color-on-pressed:var(--background-color-canvas); - --toggle-dot-margin:1px; - --toggle-dot-height:calc( +@media (forced-colors: active) { + .toggle-button { + --color-accent-primary: ButtonText; + --color-accent-primary-hover: SelectedItem; + --color-accent-primary-active: SelectedItem; + --button-background-color: ButtonFace; + --border-color-interactive: ButtonText; + --border-color-interactive-hover: SelectedItem; + --border-color-interactive-active: ButtonText; + --color-canvas: ButtonText; + --background-color-canvas: Canvas; + } +} + +.toggle-button { + --toggle-background-color: var(--button-background-color); + --toggle-background-color-hover: var(--button-background-color-hover); + --toggle-background-color-active: var(--button-background-color-active); + --toggle-background-color-pressed: var(--color-accent-primary); + --toggle-background-color-pressed-hover: var(--color-accent-primary-hover); + --toggle-background-color-pressed-active: var(--color-accent-primary-active); + --toggle-border-color: var(--border-color-interactive); + --toggle-border-color-hover: var(--toggle-border-color); + --toggle-border-color-active: var(--toggle-border-color); + --toggle-border-radius: var(--border-radius-circle); + --toggle-border-width: var(--border-width); + --toggle-height: var(--size-item-small); + --toggle-width: var(--size-item-large); + --toggle-dot-background-color: var(--toggle-border-color); + --toggle-dot-background-color-hover: var(--toggle-dot-background-color); + --toggle-dot-background-color-active: var(--toggle-dot-background-color); + --toggle-dot-background-color-on-pressed: var(--background-color-canvas); + --toggle-dot-margin: 1px; + --toggle-dot-height: calc( var(--toggle-height) - 2 * var(--toggle-dot-margin) - 2 * var(--toggle-border-width) ); - --toggle-dot-width:var(--toggle-dot-height); - --toggle-dot-transform-x:calc( + --toggle-dot-width: var(--toggle-dot-height); + --toggle-dot-transform-x: calc( var(--toggle-width) - 4 * var(--toggle-dot-margin) - var(--toggle-dot-width) ); - --input-width:var(--toggle-width); + --input-width: var(--toggle-width); - -webkit-appearance:none; + -webkit-appearance: none; - -moz-appearance:none; + -moz-appearance: none; - appearance:none; - padding:0; - border:var(--toggle-border-width) solid var(--toggle-border-color); - height:var(--toggle-height); - width:var(--toggle-width); - border-radius:var(--toggle-border-radius); - background-color:var(--toggle-background-color); - box-sizing:border-box; + appearance: none; + padding: 0; + border: var(--toggle-border-width) solid var(--toggle-border-color); + height: var(--toggle-height); + width: var(--toggle-width); + border-radius: var(--toggle-border-radius); + background-color: var(--toggle-background-color); + box-sizing: border-box; } -.toggle-button:focus-visible{ - outline:var(--focus-outline); - outline-offset:var(--focus-outline-offset); - } - -.toggle-button:enabled:hover{ - background-color:var(--toggle-background-color-hover); - border-color:var(--toggle-border-color); - } - -.toggle-button:enabled:hover:active{ - background-color:var(--toggle-background-color-active); - border-color:var(--toggle-border-color); - } - -.toggle-button::before{ - display:block; - content:""; - background-color:var(--toggle-dot-background-color); - height:var(--toggle-dot-height); - width:var(--toggle-dot-width); - margin:var(--toggle-dot-margin); - border-radius:var(--toggle-border-radius); - translate:0; - } - -.toggle-button[aria-pressed="true"]{ - background-color:var(--toggle-background-color-pressed); - border-color:transparent; +.toggle-button:focus-visible { + outline: var(--focus-outline); + outline-offset: var(--focus-outline-offset); } -.toggle-button[aria-pressed="true"]:enabled:hover{ - background-color:var(--toggle-background-color-pressed-hover); - border-color:transparent; - } +.toggle-button:enabled:hover { + background-color: var(--toggle-background-color-hover); + border-color: var(--toggle-border-color); +} -.toggle-button[aria-pressed="true"]:enabled:hover:active{ - background-color:var(--toggle-background-color-pressed-active); - border-color:transparent; - } +.toggle-button:enabled:hover:active { + background-color: var(--toggle-background-color-active); + border-color: var(--toggle-border-color); +} -.toggle-button[aria-pressed="true"]::before{ - translate:var(--toggle-dot-transform-x); - background-color:var(--toggle-dot-background-color-on-pressed); - } +.toggle-button::before { + display: block; + content: ''; + background-color: var(--toggle-dot-background-color); + height: var(--toggle-dot-height); + width: var(--toggle-dot-width); + margin: var(--toggle-dot-margin); + border-radius: var(--toggle-border-radius); + translate: 0; +} -.toggle-button[aria-pressed="true"]:enabled:hover::before,.toggle-button[aria-pressed="true"]:enabled:hover:active::before{ - background-color:var(--toggle-dot-background-color-on-pressed); - } +.toggle-button[aria-pressed='true'] { + background-color: var(--toggle-background-color-pressed); + border-color: transparent; +} -.toggle-button[aria-pressed="true"]:-moz-locale-dir(rtl)::before,[dir="rtl"] .toggle-button[aria-pressed="true"]::before{ - translate:calc(-1 * var(--toggle-dot-transform-x)); - } +.toggle-button[aria-pressed='true']:enabled:hover { + background-color: var(--toggle-background-color-pressed-hover); + border-color: transparent; +} -@media (prefers-reduced-motion: no-preference){ - .toggle-button::before{ - transition:translate 100ms; +.toggle-button[aria-pressed='true']:enabled:hover:active { + background-color: var(--toggle-background-color-pressed-active); + border-color: transparent; +} + +.toggle-button[aria-pressed='true']::before { + translate: var(--toggle-dot-transform-x); + background-color: var(--toggle-dot-background-color-on-pressed); +} + +.toggle-button[aria-pressed='true']:enabled:hover::before, +.toggle-button[aria-pressed='true']:enabled:hover:active::before { + background-color: var(--toggle-dot-background-color-on-pressed); +} + +.toggle-button[aria-pressed='true']:-moz-locale-dir(rtl)::before, +[dir='rtl'] .toggle-button[aria-pressed='true']::before { + translate: calc(-1 * var(--toggle-dot-transform-x)); +} + +@media (prefers-reduced-motion: no-preference) { + .toggle-button::before { + transition: translate 100ms; } } -@media (prefers-contrast){ - .toggle-button:enabled:hover{ - border-color:var(--toggle-border-color-hover); +@media (prefers-contrast) { + .toggle-button:enabled:hover { + border-color: var(--toggle-border-color-hover); } - .toggle-button:enabled:hover:active{ - border-color:var(--toggle-border-color-active); + .toggle-button:enabled:hover:active { + border-color: var(--toggle-border-color-active); } - .toggle-button[aria-pressed="true"]:enabled{ - border-color:var(--toggle-border-color); - position:relative; + .toggle-button[aria-pressed='true']:enabled { + border-color: var(--toggle-border-color); + position: relative; } - .toggle-button[aria-pressed="true"]:enabled:hover{ - border-color:var(--toggle-border-color-hover); - } + .toggle-button[aria-pressed='true']:enabled:hover { + border-color: var(--toggle-border-color-hover); + } - .toggle-button[aria-pressed="true"]:enabled:hover:active{ - background-color:var(--toggle-dot-background-color-active); - border-color:var(--toggle-dot-background-color-hover); - } + .toggle-button[aria-pressed='true']:enabled:hover:active { + background-color: var(--toggle-dot-background-color-active); + border-color: var(--toggle-dot-background-color-hover); + } .toggle-button:enabled:hover::before, - .toggle-button:enabled:hover:active::before{ - background-color:var(--toggle-dot-background-color-hover); + .toggle-button:enabled:hover:active::before { + background-color: var(--toggle-dot-background-color-hover); } } -@media (forced-colors){ - .toggle-button{ - --toggle-dot-background-color:var(--color-accent-primary); - --toggle-dot-background-color-hover:var(--color-accent-primary-hover); - --toggle-dot-background-color-active:var(--color-accent-primary-active); - --toggle-dot-background-color-on-pressed:var(--button-background-color); - --toggle-border-color-hover:var(--border-color-interactive-hover); - --toggle-border-color-active:var(--border-color-interactive-active); +@media (forced-colors) { + .toggle-button { + --toggle-dot-background-color: var(--color-accent-primary); + --toggle-dot-background-color-hover: var(--color-accent-primary-hover); + --toggle-dot-background-color-active: var(--color-accent-primary-active); + --toggle-dot-background-color-on-pressed: var(--button-background-color); + --toggle-border-color-hover: var(--border-color-interactive-hover); + --toggle-border-color-active: var(--border-color-interactive-active); } - .toggle-button[aria-pressed="true"]:enabled::after{ - border:1px solid var(--button-background-color); - content:""; - position:absolute; - height:var(--toggle-height); - width:var(--toggle-width); - display:block; - border-radius:var(--toggle-border-radius); - inset:-2px; + .toggle-button[aria-pressed='true']:enabled::after { + border: 1px solid var(--button-background-color); + content: ''; + position: absolute; + height: var(--toggle-height); + width: var(--toggle-width); + display: block; + border-radius: var(--toggle-border-radius); + inset: -2px; } - .toggle-button[aria-pressed="true"]:enabled:hover:active::after{ - border-color:var(--toggle-border-color-active); + .toggle-button[aria-pressed='true']:enabled:hover:active::after { + border-color: var(--toggle-border-color-active); } } -:root{ - --clear-signature-button-icon:url(images/editor-toolbar-delete.svg); - --csstools-light-dark-toggle--71:var(--csstools-color-scheme--light) #2b2a33; - --signature-bg:var(--csstools-light-dark-toggle--71, #f9f9fb); - --csstools-light-dark-toggle--72:var(--csstools-color-scheme--light) var(--signature-bg); - --signature-hover-bg:var(--csstools-light-dark-toggle--72, #f0f0f4); - --button-signature-bg:transparent; - --button-signature-color:var(--main-color); - --csstools-light-dark-toggle--73:var(--csstools-color-scheme--light) #5b5b66; - --button-signature-active-bg:var(--csstools-light-dark-toggle--73, #cfcfd8); - --button-signature-active-border:none; - --button-signature-active-color:var(--button-signature-color); - --button-signature-border:none; - --csstools-light-dark-toggle--74:var(--csstools-color-scheme--light) #52525e; - --button-signature-hover-bg:var(--csstools-light-dark-toggle--74, #e0e0e6); - --button-signature-hover-color:var(--button-signature-color); +:root { + --clear-signature-button-icon: url(images/editor-toolbar-delete.svg); + --csstools-light-dark-toggle--71: var(--csstools-color-scheme--light) #2b2a33; + --signature-bg: var(--csstools-light-dark-toggle--71, #f9f9fb); + --csstools-light-dark-toggle--72: var(--csstools-color-scheme--light) + var(--signature-bg); + --signature-hover-bg: var(--csstools-light-dark-toggle--72, #f0f0f4); + --button-signature-bg: transparent; + --button-signature-color: var(--main-color); + --csstools-light-dark-toggle--73: var(--csstools-color-scheme--light) #5b5b66; + --button-signature-active-bg: var(--csstools-light-dark-toggle--73, #cfcfd8); + --button-signature-active-border: none; + --button-signature-active-color: var(--button-signature-color); + --button-signature-border: none; + --csstools-light-dark-toggle--74: var(--csstools-color-scheme--light) #52525e; + --button-signature-hover-bg: var(--csstools-light-dark-toggle--74, #e0e0e6); + --button-signature-hover-color: var(--button-signature-color); } -@supports (color: light-dark(red, red)){ -:root{ - --signature-bg:light-dark(#f9f9fb, #2b2a33); - --signature-hover-bg:light-dark(#f0f0f4, var(--signature-bg)); - --button-signature-active-bg:light-dark(#cfcfd8, #5b5b66); - --button-signature-hover-bg:light-dark(#e0e0e6, #52525e); -} -} - -@supports not (color: light-dark(tan, tan)){ - -:root *{ - --csstools-light-dark-toggle--71:var(--csstools-color-scheme--light) #2b2a33; - --signature-bg:var(--csstools-light-dark-toggle--71, #f9f9fb); - --csstools-light-dark-toggle--72:var(--csstools-color-scheme--light) var(--signature-bg); - --signature-hover-bg:var(--csstools-light-dark-toggle--72, #f0f0f4); - --csstools-light-dark-toggle--73:var(--csstools-color-scheme--light) #5b5b66; - --button-signature-active-bg:var(--csstools-light-dark-toggle--73, #cfcfd8); - --csstools-light-dark-toggle--74:var(--csstools-color-scheme--light) #52525e; - --button-signature-hover-bg:var(--csstools-light-dark-toggle--74, #e0e0e6); +@supports (color: light-dark(red, red)) { + :root { + --signature-bg: light-dark(#f9f9fb, #2b2a33); + --signature-hover-bg: light-dark(#f0f0f4, var(--signature-bg)); + --button-signature-active-bg: light-dark(#cfcfd8, #5b5b66); + --button-signature-hover-bg: light-dark(#e0e0e6, #52525e); } } -@media screen and (forced-colors: active){ - -:root{ - --signature-bg:HighlightText; - --signature-hover-bg:var(--signature-bg); - --button-signature-bg:HighlightText; - --button-signature-color:ButtonText; - --button-signature-active-bg:ButtonText; - --button-signature-active-color:HighlightText; - --button-signature-border:1px solid ButtonText; - --button-signature-hover-bg:Highlight; - --button-signature-hover-color:HighlightText; -} +@supports not (color: light-dark(tan, tan)) { + :root * { + --csstools-light-dark-toggle--71: var(--csstools-color-scheme--light) + #2b2a33; + --signature-bg: var(--csstools-light-dark-toggle--71, #f9f9fb); + --csstools-light-dark-toggle--72: var(--csstools-color-scheme--light) + var(--signature-bg); + --signature-hover-bg: var(--csstools-light-dark-toggle--72, #f0f0f4); + --csstools-light-dark-toggle--73: var(--csstools-color-scheme--light) + #5b5b66; + --button-signature-active-bg: var( + --csstools-light-dark-toggle--73, + #cfcfd8 + ); + --csstools-light-dark-toggle--74: var(--csstools-color-scheme--light) + #52525e; + --button-signature-hover-bg: var(--csstools-light-dark-toggle--74, #e0e0e6); } - -.signatureDialog{ - --primary-color:var(--text-primary-color); - --border-color:#8f8f9d; - --open-link-fg:var(--link-fg-color); - --open-link-hover-fg:var(--link-hover-fg-color); } -@media screen and (forced-colors: active){ - -.signatureDialog{ - --primary-color:ButtonText; - --border-color:ButtonText; - --open-link-fg:ButtonText; - --open-link-hover-fg:ButtonText; -} +@media screen and (forced-colors: active) { + :root { + --signature-bg: HighlightText; + --signature-hover-bg: var(--signature-bg); + --button-signature-bg: HighlightText; + --button-signature-color: ButtonText; + --button-signature-active-bg: ButtonText; + --button-signature-active-color: HighlightText; + --button-signature-border: 1px solid ButtonText; + --button-signature-hover-bg: Highlight; + --button-signature-hover-color: HighlightText; } - -.signatureDialog{ - - width:570px; - max-width:100%; - min-width:300px; - padding:16px 0; } -.signatureDialog .mainContainer{ - width:100%; - display:flex; - flex-direction:column; - align-items:flex-start; - gap:12px; +.signatureDialog { + --primary-color: var(--text-primary-color); + --border-color: #8f8f9d; + --open-link-fg: var(--link-fg-color); + --open-link-hover-fg: var(--link-hover-fg-color); +} + +@media screen and (forced-colors: active) { + .signatureDialog { + --primary-color: ButtonText; + --border-color: ButtonText; + --open-link-fg: ButtonText; + --open-link-hover-fg: ButtonText; } +} -:is(.signatureDialog .mainContainer) span:not([role="sectionhead"]){ - font-size:13px; - font-style:normal; - font-weight:400; - line-height:normal; - } +.signatureDialog { + width: 570px; + max-width: 100%; + min-width: 300px; + padding: 16px 0; +} -:is(.signatureDialog .mainContainer) .title{ - margin-inline-start:16px; - } +.signatureDialog .mainContainer { + width: 100%; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 12px; +} -.signatureDialog .inputWithClearButton{ - --button-dimension:24px; - --clear-button-icon:url(images/messageBar_closingButton.svg); +:is(.signatureDialog .mainContainer) span:not([role='sectionhead']) { + font-size: 13px; + font-style: normal; + font-weight: 400; + line-height: normal; +} - width:100%; - position:relative; - display:flex; - align-items:center; - justify-content:center; - } +:is(.signatureDialog .mainContainer) .title { + margin-inline-start: 16px; +} -:is(.signatureDialog .inputWithClearButton) > input{ - width:100%; - height:32px; - padding-inline:8px calc(4px + var(--button-dimension)); - box-sizing:border-box; - border-radius:4px; - border:1px solid var(--border-color); - } +.signatureDialog .inputWithClearButton { + --button-dimension: 24px; + --clear-button-icon: url(images/messageBar_closingButton.svg); -:is(.signatureDialog .inputWithClearButton) .clearInputButton{ - position:absolute; - inset-block-start:4px; - inset-inline-end:4px; - display:inline-block; - width:var(--button-dimension); - height:var(--button-dimension); - background-color:var(--input-text-fg-color); - -webkit-mask-size:cover; - mask-size:cover; - -webkit-mask-image:var(--clear-button-icon); - mask-image:var(--clear-button-icon); - padding:0; - border:0; - } + width: 100%; + position: relative; + display: flex; + align-items: center; + justify-content: center; +} -#addSignatureDialog{ - --secondary-color:var(--text-secondary-color); - --bg-hover:#e0e0e6; - --tab-top-line-active-color:#0060df; - --tab-top-line-active-hover-color:var(--tab-text-hover-color); - --tab-top-line-hover-color:#8f8f9d; - --tab-top-line-inactive-color:#cfcfd8; - --tab-bottom-line-active-color:var(--tab-top-line-inactive-color); - --tab-bottom-line-hover-color:var(--tab-top-line-inactive-color); - --tab-bottom-line-inactive-color:var(--tab-top-line-inactive-color); - --tab-bg:var(--dialog-bg-color); - --tab-bg-active-color:var(--tab-bg); - --tab-bg-active-hover-color:var(--bg-hover); - --tab-bg-hover:var(--bg-hover); - --tab-panel-border:none; - --tab-panel-border-radius:4px; - --tab-text-color:var(--primary-color); - --tab-text-active-color:var(--tab-top-line-active-color); - --tab-text-active-hover-color:var(--tab-text-hover-color); - --tab-text-hover-color:var(--tab-text-color); - --signature-placeholder-color:var(--secondary-color); - --signature-draw-placeholder-color:var(--primary-color); - --signature-color:var(--primary-color); - --clear-signature-button-border-width:0; - --clear-signature-button-border-style:solid; - --clear-signature-button-border-color:transparent; - --clear-signature-button-border-disabled-color:transparent; - --clear-signature-button-color:var(--primary-color); - --clear-signature-button-hover-color:var(--clear-signature-button-color); - --clear-signature-button-active-color:var(--clear-signature-button-color); - --clear-signature-button-disabled-color:var(--clear-signature-button-color); - --clear-signature-button-focus-color:var(--clear-signature-button-color); - --clear-signature-button-bg:var(--dialog-bg-color); - --clear-signature-button-bg-hover:var(--bg-hover); - --clear-signature-button-bg-active:#cfcfd8; - --clear-signature-button-bg-focus:#f0f0f4; - --clear-signature-button-bg-disabled:color-mix( +:is(.signatureDialog .inputWithClearButton) > input { + width: 100%; + height: 32px; + padding-inline: 8px calc(4px + var(--button-dimension)); + box-sizing: border-box; + border-radius: 4px; + border: 1px solid var(--border-color); +} + +:is(.signatureDialog .inputWithClearButton) .clearInputButton { + position: absolute; + inset-block-start: 4px; + inset-inline-end: 4px; + display: inline-block; + width: var(--button-dimension); + height: var(--button-dimension); + background-color: var(--input-text-fg-color); + -webkit-mask-size: cover; + mask-size: cover; + -webkit-mask-image: var(--clear-button-icon); + mask-image: var(--clear-button-icon); + padding: 0; + border: 0; +} + +#addSignatureDialog { + --secondary-color: var(--text-secondary-color); + --bg-hover: #e0e0e6; + --tab-top-line-active-color: #0060df; + --tab-top-line-active-hover-color: var(--tab-text-hover-color); + --tab-top-line-hover-color: #8f8f9d; + --tab-top-line-inactive-color: #cfcfd8; + --tab-bottom-line-active-color: var(--tab-top-line-inactive-color); + --tab-bottom-line-hover-color: var(--tab-top-line-inactive-color); + --tab-bottom-line-inactive-color: var(--tab-top-line-inactive-color); + --tab-bg: var(--dialog-bg-color); + --tab-bg-active-color: var(--tab-bg); + --tab-bg-active-hover-color: var(--bg-hover); + --tab-bg-hover: var(--bg-hover); + --tab-panel-border: none; + --tab-panel-border-radius: 4px; + --tab-text-color: var(--primary-color); + --tab-text-active-color: var(--tab-top-line-active-color); + --tab-text-active-hover-color: var(--tab-text-hover-color); + --tab-text-hover-color: var(--tab-text-color); + --signature-placeholder-color: var(--secondary-color); + --signature-draw-placeholder-color: var(--primary-color); + --signature-color: var(--primary-color); + --clear-signature-button-border-width: 0; + --clear-signature-button-border-style: solid; + --clear-signature-button-border-color: transparent; + --clear-signature-button-border-disabled-color: transparent; + --clear-signature-button-color: var(--primary-color); + --clear-signature-button-hover-color: var(--clear-signature-button-color); + --clear-signature-button-active-color: var(--clear-signature-button-color); + --clear-signature-button-disabled-color: var(--clear-signature-button-color); + --clear-signature-button-focus-color: var(--clear-signature-button-color); + --clear-signature-button-bg: var(--dialog-bg-color); + --clear-signature-button-bg-hover: var(--bg-hover); + --clear-signature-button-bg-active: #cfcfd8; + --clear-signature-button-bg-focus: #f0f0f4; + --clear-signature-button-bg-disabled: color-mix( in srgb, #f0f0f4, transparent 40% ); - --save-warning-color:var(--secondary-color); - --thickness-bg:var(--dialog-bg-color); - --thickness-label-color:var(--primary-color); - --thickness-slider-color:var(--primary-color); - --thickness-border:none; - --draw-cursor:url(images/cursor-editorInk.svg) 0 16, pointer; + --save-warning-color: var(--secondary-color); + --thickness-bg: var(--dialog-bg-color); + --thickness-label-color: var(--primary-color); + --thickness-slider-color: var(--primary-color); + --thickness-border: none; + --draw-cursor: url(images/cursor-editorInk.svg) 0 16, pointer; } -@media (prefers-color-scheme: dark){ - -#addSignatureDialog{ - --dialog-bg-color:#42414d; - --bg-hover:#52525e; - --primary-color:#fbfbfe; - --secondary-color:#cfcfd8; - --tab-top-line-active-color:#0df; - --tab-top-line-inactive-color:#8f8f9d; - --clear-signature-button-bg-active:#5b5b66; - --clear-signature-button-bg-focus:#2b2a33; - --clear-signature-button-bg-disabled:color-mix( +@media (prefers-color-scheme: dark) { + #addSignatureDialog { + --dialog-bg-color: #42414d; + --bg-hover: #52525e; + --primary-color: #fbfbfe; + --secondary-color: #cfcfd8; + --tab-top-line-active-color: #0df; + --tab-top-line-inactive-color: #8f8f9d; + --clear-signature-button-bg-active: #5b5b66; + --clear-signature-button-bg-focus: #2b2a33; + --clear-signature-button-bg-disabled: color-mix( in srgb, #2b2a33, transparent 40% ); + } } + +@media screen and (forced-colors: active) { + #addSignatureDialog { + --secondary-color: ButtonText; + --bg: HighlightText; + --bg-hover: var(--bg); + --tab-top-line-active-color: ButtonText; + --tab-top-line-active-hover-color: HighlightText; + --tab-top-line-hover-color: SelectedItem; + --tab-top-line-inactive-color: ButtonText; + --tab-bottom-line-active-color: var(--tab-top-line-active-color); + --tab-bottom-line-hover-color: var(--tab-top-line-hover-color); + --tab-bg: var(--bg); + --tab-bg-active-color: SelectedItem; + --tab-bg-active-hover-color: SelectedItem; + --tab-panel-border: 1px solid ButtonText; + --tab-panel-border-radius: 8px; + --tab-text-color: ButtonText; + --tab-text-active-color: HighlightText; + --tab-text-active-hover-color: HighlightText; + --tab-text-hover-color: SelectedItem; + --signature-color: ButtonText; + --clear-signature-button-border-width: 1px; + --clear-signature-button-border-style: solid; + --clear-signature-button-border-color: ButtonText; + --clear-signature-button-border-disabled-color: GrayText; + --clear-signature-button-color: ButtonText; + --clear-signature-button-hover-color: HighlightText; + --clear-signature-button-active-color: SelectedItem; + --clear-signature-button-focus-color: CanvasText; + --clear-signature-button-disabled-color: GrayText; + --clear-signature-button-bg: var(--bg); + --clear-signature-button-bg-hover: SelectedItem; + --clear-signature-button-bg-active: var(--bg); + --clear-signature-button-bg-focus: var(--bg); + --clear-signature-button-bg-disabled: var(--bg); + --thickness-bg: Canvas; + --thickness-label-color: CanvasText; + --thickness-slider-color: ButtonText; + --thickness-border: 1px solid var(--border-color); } - -@media screen and (forced-colors: active){ - -#addSignatureDialog{ - --secondary-color:ButtonText; - --bg:HighlightText; - --bg-hover:var(--bg); - --tab-top-line-active-color:ButtonText; - --tab-top-line-active-hover-color:HighlightText; - --tab-top-line-hover-color:SelectedItem; - --tab-top-line-inactive-color:ButtonText; - --tab-bottom-line-active-color:var(--tab-top-line-active-color); - --tab-bottom-line-hover-color:var(--tab-top-line-hover-color); - --tab-bg:var(--bg); - --tab-bg-active-color:SelectedItem; - --tab-bg-active-hover-color:SelectedItem; - --tab-panel-border:1px solid ButtonText; - --tab-panel-border-radius:8px; - --tab-text-color:ButtonText; - --tab-text-active-color:HighlightText; - --tab-text-active-hover-color:HighlightText; - --tab-text-hover-color:SelectedItem; - --signature-color:ButtonText; - --clear-signature-button-border-width:1px; - --clear-signature-button-border-style:solid; - --clear-signature-button-border-color:ButtonText; - --clear-signature-button-border-disabled-color:GrayText; - --clear-signature-button-color:ButtonText; - --clear-signature-button-hover-color:HighlightText; - --clear-signature-button-active-color:SelectedItem; - --clear-signature-button-focus-color:CanvasText; - --clear-signature-button-disabled-color:GrayText; - --clear-signature-button-bg:var(--bg); - --clear-signature-button-bg-hover:SelectedItem; - --clear-signature-button-bg-active:var(--bg); - --clear-signature-button-bg-focus:var(--bg); - --clear-signature-button-bg-disabled:var(--bg); - --thickness-bg:Canvas; - --thickness-label-color:CanvasText; - --thickness-slider-color:ButtonText; - --thickness-border:1px solid var(--border-color); } - } -#addSignatureDialog #addSignatureDialogLabel{ - overflow:hidden; - position:absolute; - inset:0; - width:0; - height:0; - } +#addSignatureDialog #addSignatureDialogLabel { + overflow: hidden; + position: absolute; + inset: 0; + width: 0; + height: 0; +} -#addSignatureDialog.waiting::after{ - content:""; - cursor:wait; - position:absolute; - inset:0; - width:100%; - height:100%; - } +#addSignatureDialog.waiting::after { + content: ''; + cursor: wait; + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} -:is(#addSignatureDialog .mainContainer) [role="tablist"]{ - width:100%; - display:flex; - align-items:flex-start; - gap:0; - } +:is(#addSignatureDialog .mainContainer) [role='tablist'] { + width: 100%; + display: flex; + align-items: flex-start; + gap: 0; +} -:is(:is(#addSignatureDialog .mainContainer) [role="tablist"]) > [role="tab"]{ - flex:1 0 0; - align-self:stretch; - background-color:var(--tab-bg); - padding-inline:0; - cursor:default; +:is(:is(#addSignatureDialog .mainContainer) [role='tablist']) > [role='tab'] { + flex: 1 0 0; + align-self: stretch; + background-color: var(--tab-bg); + padding-inline: 0; + cursor: default; - border-inline:0; - border-block-width:1px; - border-block-style:solid; - border-block-start-color:var(--tab-top-line-inactive-color); - border-block-end-color:var(--tab-bottom-line-inactive-color); - border-radius:0; + border-inline: 0; + border-block-width: 1px; + border-block-style: solid; + border-block-start-color: var(--tab-top-line-inactive-color); + border-block-end-color: var(--tab-bottom-line-inactive-color); + border-radius: 0; - font:menu; - font-size:13px; - font-style:normal; - line-height:normal; - font-weight:400; - color:var(--tab-text-color); - } + font: menu; + font-size: 13px; + font-style: normal; + line-height: normal; + font-weight: 400; + color: var(--tab-text-color); +} -:is(:is(:is(#addSignatureDialog .mainContainer) [role="tablist"]) > [role="tab"]):hover{ - border-block-start-width:2px; - border-block-start-color:var(--tab-top-line-hover-color); - border-block-end-color:var(--tab-bottom-line-hover-color); - background-color:var(--tab-bg-hover); - color:var(--tab-text-hover-color); - } +:is( + :is(:is(#addSignatureDialog .mainContainer) [role='tablist']) > [role='tab'] +):hover { + border-block-start-width: 2px; + border-block-start-color: var(--tab-top-line-hover-color); + border-block-end-color: var(--tab-bottom-line-hover-color); + background-color: var(--tab-bg-hover); + color: var(--tab-text-hover-color); +} -:is(:is(:is(#addSignatureDialog .mainContainer) [role="tablist"]) > [role="tab"]):focus-visible{ - outline:2px solid var(--tab-top-line-active-color); - outline-offset:-2px; - } +:is( + :is(:is(#addSignatureDialog .mainContainer) [role='tablist']) > [role='tab'] +):focus-visible { + outline: 2px solid var(--tab-top-line-active-color); + outline-offset: -2px; +} -[aria-selected="true"]:is(:is(:is(#addSignatureDialog .mainContainer) [role="tablist"]) > [role="tab"]){ - border-block-start-width:2px; - border-block-start-color:var(--tab-top-line-active-color); - border-block-end-color:var(--tab-bottom-line-active-color); - background-color:var(--tab-bg-active-color); - font-weight:590; - color:var(--tab-text-active-color); - } +[aria-selected='true']:is( + :is(:is(#addSignatureDialog .mainContainer) [role='tablist']) > [role='tab'] +) { + border-block-start-width: 2px; + border-block-start-color: var(--tab-top-line-active-color); + border-block-end-color: var(--tab-bottom-line-active-color); + background-color: var(--tab-bg-active-color); + font-weight: 590; + color: var(--tab-text-active-color); +} -[aria-selected="true"]:is(:is(:is(#addSignatureDialog .mainContainer) [role="tablist"]) > [role="tab"]):hover{ - border-block-start-color:var(--tab-top-line-active-hover-color); - background-color:var(--tab-bg-active-hover-color); - color:var(--tab-text-active-hover-color); - } +[aria-selected='true']:is( + :is(:is(#addSignatureDialog .mainContainer) [role='tablist']) > [role='tab'] + ):hover { + border-block-start-color: var(--tab-top-line-active-hover-color); + background-color: var(--tab-bg-active-hover-color); + color: var(--tab-text-active-hover-color); +} -:is(#addSignatureDialog .mainContainer) #addSignatureActionContainer{ - width:100%; - height:auto; - display:flex; - flex-direction:column; - align-items:flex-end; - align-self:stretch; - gap:12px; - padding-inline:16px; - box-sizing:border-box; - } +:is(#addSignatureDialog .mainContainer) #addSignatureActionContainer { + width: 100%; + height: auto; + display: flex; + flex-direction: column; + align-items: flex-end; + align-self: stretch; + gap: 12px; + padding-inline: 16px; + box-sizing: border-box; +} -:is(:is(#addSignatureDialog .mainContainer) #addSignatureActionContainer) > [role="tabpanel"]{ - position:relative; - width:100%; - height:220px; - background-color:var(--signature-bg); - border:var(--tab-panel-border); - border-radius:var(--tab-panel-border-radius); - } +:is(:is(#addSignatureDialog .mainContainer) #addSignatureActionContainer) + > [role='tabpanel'] { + position: relative; + width: 100%; + height: 220px; + background-color: var(--signature-bg); + border: var(--tab-panel-border); + border-radius: var(--tab-panel-border-radius); +} -:is(:is(:is(#addSignatureDialog .mainContainer) #addSignatureActionContainer) > [role="tabpanel"]) > svg{ - position:absolute; - inset:0; - width:100%; - height:100%; - background-color:transparent; - } +:is( + :is(:is(#addSignatureDialog .mainContainer) #addSignatureActionContainer) + > [role='tabpanel'] + ) + > svg { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + background-color: transparent; +} -#addSignatureTypeContainer:is(:is(:is(#addSignatureDialog .mainContainer) #addSignatureActionContainer) > [role="tabpanel"]){ - display:none; - } +#addSignatureTypeContainer:is( + :is(:is(#addSignatureDialog .mainContainer) #addSignatureActionContainer) + > [role='tabpanel'] +) { + display: none; +} /* Custom handwriting fonts for signature type input */ @font-face { - font-family: "Kalam"; - src: url("./standard_fonts/Kalam.ttf") format("truetype"); - font-weight: 400; - font-style: normal; - font-display: swap; -} - -@font-face { - font-family: "Sacramento"; - src: url("./standard_fonts/Sacramento.ttf") format("truetype"); - font-weight: 400; - font-style: normal; - font-display: swap; -} - -@font-face { - font-family: "Alex"; - src: url("./standard_fonts/AlexBrush.ttf") format("truetype"); - font-weight: 400; - font-style: normal; - font-display: swap; -} - -@font-face { - font-family: "Allura"; - src: url("./standard_fonts/Allura.ttf") format("truetype"); + font-family: 'Kalam'; + src: url('./standard_fonts/Kalam.ttf') format('truetype'); font-weight: 400; font-style: normal; font-display: swap; } @font-face { - font-family: "Handlee"; - src: url("./standard_fonts/Handlee.ttf") format("truetype"); + font-family: 'Sacramento'; + src: url('./standard_fonts/Sacramento.ttf') format('truetype'); + font-weight: 400; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: 'Alex'; + src: url('./standard_fonts/AlexBrush.ttf') format('truetype'); + font-weight: 400; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: 'Allura'; + src: url('./standard_fonts/Allura.ttf') format('truetype'); + font-weight: 400; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: 'Handlee'; + src: url('./standard_fonts/Handlee.ttf') format('truetype'); font-weight: 400; font-style: normal; font-display: swap; @@ -2445,5071 +2665,7620 @@ /* Custom end */ -#addSignatureTypeContainer:is(:is(:is(#addSignatureDialog .mainContainer) #addSignatureActionContainer) > [role="tabpanel"]) #addSignatureTypeInput{ - position:absolute; - inset:0; - width:100%; - height:100%; - border:0; - padding:0; - text-align:center; - color:var(--signature-color); - background-color:transparent; - border-radius:var(--tab-panel-border-radius); +#addSignatureTypeContainer:is( + :is(:is(#addSignatureDialog .mainContainer) #addSignatureActionContainer) + > [role='tabpanel'] + ) + #addSignatureTypeInput { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + border: 0; + padding: 0; + text-align: center; + color: var(--signature-color); + background-color: transparent; + border-radius: var(--tab-panel-border-radius); - font-family:"Brush script", "Apple Chancery", "Segoe script", "Freestyle Script", "Palace Script MT", "Brush Script MT", TK, cursive, serif; - font-size:44px; - font-style:italic; - font-weight:400; - } - -#signatureTypeControls{ - position:absolute; - inset-inline:8px; - inset-block-start:8px; - display:flex; - align-items:center; - gap:8px; - padding:4px 8px; - background-color:rgba(0,0,0,0.4); - border-radius:4px; - z-index:1; -} -#signatureTypeControls label{ - font-size:11px; - color:var(--secondary-text-color); -} -#signatureFontSelect{ - font-size:11px; -} -#signatureColorPicker{ - width:20px; - height:20px; - padding:0; - border:none; - background:transparent; + font-family: + 'Brush script', 'Apple Chancery', 'Segoe script', 'Freestyle Script', + 'Palace Script MT', 'Brush Script MT', TK, cursive, serif; + font-size: 44px; + font-style: italic; + font-weight: 400; } -:is(#addSignatureTypeContainer:is(:is(:is(#addSignatureDialog .mainContainer) #addSignatureActionContainer) > [role="tabpanel"]) #addSignatureTypeInput)::-moz-placeholder{ - color:var(--signature-placeholder-color); - text-align:center; - - font:menu; - font-style:normal; - font-weight:274; - font-size:44px; - line-height:normal; - } - -:is(#addSignatureTypeContainer:is(:is(:is(#addSignatureDialog .mainContainer) #addSignatureActionContainer) > [role="tabpanel"]) #addSignatureTypeInput)::placeholder{ - color:var(--signature-placeholder-color); - text-align:center; - - font:menu; - font-style:normal; - font-weight:274; - font-size:44px; - line-height:normal; - } - -#addSignatureDrawContainer:is(:is(:is(#addSignatureDialog .mainContainer) #addSignatureActionContainer) > [role="tabpanel"]){ - display:none; - } - -#addSignatureDrawContainer:is(:is(:is(#addSignatureDialog .mainContainer) #addSignatureActionContainer) > [role="tabpanel"]) > span{ - position:absolute; - top:0; - left:0; - width:100%; - height:100%; - display:grid; - align-items:center; - justify-content:center; - - background-color:transparent; - color:var(--signature-placeholder-color); - -webkit-user-select:none; - -moz-user-select:none; - user-select:none; - } - -#addSignatureDrawContainer:is(:is(:is(#addSignatureDialog .mainContainer) #addSignatureActionContainer) > [role="tabpanel"]) > svg{ - stroke:var(--signature-color); - fill:none; - stroke-opacity:1; - stroke-linecap:round; - stroke-linejoin:round; - stroke-miterlimit:10; - } - -:is(#addSignatureDrawContainer:is(:is(:is(#addSignatureDialog .mainContainer) #addSignatureActionContainer) > [role="tabpanel"]) > svg):hover{ - cursor:var(--draw-cursor); - } - -#addSignatureDrawContainer:is(:is(:is(#addSignatureDialog .mainContainer) #addSignatureActionContainer) > [role="tabpanel"]) #thickness{ - position:absolute; - width:100%; - inset-block-end:0; - display:grid; - align-items:center; - justify-content:center; - pointer-events:none; - } - -:is(#addSignatureDrawContainer:is(:is(:is(#addSignatureDialog .mainContainer) #addSignatureActionContainer) > [role="tabpanel"]) #thickness) > span{ - color:var(--signature-draw-placeholder-color); - } - -:is(#addSignatureDrawContainer:is(:is(:is(#addSignatureDialog .mainContainer) #addSignatureActionContainer) > [role="tabpanel"]) #thickness) > div{ - width:auto; - height:auto; - display:flex; - align-items:center; - justify-content:center; - gap:8px; - padding:6px 8px 7px; - margin:0; - background-color:var(--thickness-bg); - border-radius:4px 4px 0 0; - border-inline:var(--thickness-border); - border-top:var(--thickness-border); - pointer-events:auto; - position:relative; - top:1px; - } - -:is(:is(#addSignatureDrawContainer:is(:is(:is(#addSignatureDialog .mainContainer) #addSignatureActionContainer) > [role="tabpanel"]) #thickness) > div) > label{ - color:var(--thickness-label-color); - } - -:is(:is(#addSignatureDrawContainer:is(:is(:is(#addSignatureDialog .mainContainer) #addSignatureActionContainer) > [role="tabpanel"]) #thickness) > div) > input{ - width:100px; - height:14px; - background-color:transparent; - } - -:is(:is(:is(#addSignatureDrawContainer:is(:is(:is(#addSignatureDialog .mainContainer) #addSignatureActionContainer) > [role="tabpanel"]) #thickness) > div) > input)::-webkit-slider-runnable-track,:is(:is(:is(#addSignatureDrawContainer:is(:is(:is(#addSignatureDialog .mainContainer) #addSignatureActionContainer) > [role="tabpanel"]) #thickness) > div) > input)::-moz-range-track,:is(:is(:is(#addSignatureDrawContainer:is(:is(:is(#addSignatureDialog .mainContainer) #addSignatureActionContainer) > [role="tabpanel"]) #thickness) > div) > input)::-moz-range-progress{ - background-color:var(--thickness-slider-color); - } - -:is(:is(:is(#addSignatureDrawContainer:is(:is(:is(#addSignatureDialog .mainContainer) #addSignatureActionContainer) > [role="tabpanel"]) #thickness) > div) > input)::-webkit-slider-thumb,:is(:is(:is(#addSignatureDrawContainer:is(:is(:is(#addSignatureDialog .mainContainer) #addSignatureActionContainer) > [role="tabpanel"]) #thickness) > div) > input)::-moz-range-thumb{ - background-color:var(--thickness-bg); - } - -:is(:is(#addSignatureDrawContainer:is(:is(:is(#addSignatureDialog .mainContainer) #addSignatureActionContainer) > [role="tabpanel"]) #thickness) > div) > input{ - - border-radius:4.5px; - border:0; - color:var(--signature-color); - } - -#addSignatureImageContainer:is(:is(:is(#addSignatureDialog .mainContainer) #addSignatureActionContainer) > [role="tabpanel"]){ - display:none; - } - -#addSignatureImageContainer:is(:is(:is(#addSignatureDialog .mainContainer) #addSignatureActionContainer) > [role="tabpanel"]) > svg{ - stroke:none; - stroke-width:0; - fill:var(--signature-color); - fill-opacity:1; - } - -#addSignatureImageContainer:is(:is(:is(#addSignatureDialog .mainContainer) #addSignatureActionContainer) > [role="tabpanel"]) #addSignatureImagePlaceholder{ - position:absolute; - top:0; - left:0; - width:100%; - height:100%; - background-color:transparent; - display:flex; - flex-direction:column; - align-items:center; - justify-content:center; - } - -:is(#addSignatureImageContainer:is(:is(:is(#addSignatureDialog .mainContainer) #addSignatureActionContainer) > [role="tabpanel"]) #addSignatureImagePlaceholder) span{ - color:var(--signature-placeholder-color); - } - -:is(#addSignatureImageContainer:is(:is(:is(#addSignatureDialog .mainContainer) #addSignatureActionContainer) > [role="tabpanel"]) #addSignatureImagePlaceholder) a{ - color:var(--open-link-fg); - text-decoration:underline; - cursor:pointer; - } - -:is(:is(#addSignatureImageContainer:is(:is(:is(#addSignatureDialog .mainContainer) #addSignatureActionContainer) > [role="tabpanel"]) #addSignatureImagePlaceholder) a):hover{ - color:var(--open-link-hover-fg); - } - -#addSignatureImageContainer:is(:is(:is(#addSignatureDialog .mainContainer) #addSignatureActionContainer) > [role="tabpanel"]) #addSignatureFilePicker{ - visibility:hidden; - position:relative; - width:0; - height:0; - } - -[data-selected="type"]:is(:is(#addSignatureDialog .mainContainer) #addSignatureActionContainer) > #addSignatureTypeContainer,[data-selected="draw"]:is(:is(#addSignatureDialog .mainContainer) #addSignatureActionContainer) > #addSignatureDrawContainer,[data-selected="image"]:is(:is(#addSignatureDialog .mainContainer) #addSignatureActionContainer) > #addSignatureImageContainer{ - display:block; - } - -:is(:is(#addSignatureDialog .mainContainer) #addSignatureActionContainer) #addSignatureControls{ - display:flex; - flex-direction:column; - justify-content:center; - align-items:flex-start; - gap:12px; - align-self:stretch; - } - -:is(:is(:is(#addSignatureDialog .mainContainer) #addSignatureActionContainer) #addSignatureControls) #horizontalContainer{ - display:flex; - align-items:flex-end; - gap:16px; - align-self:stretch; - } - -:is(:is(:is(:is(#addSignatureDialog .mainContainer) #addSignatureActionContainer) #addSignatureControls) #horizontalContainer) #addSignatureDescriptionContainer{ - display:flex; - flex-direction:column; - align-items:flex-start; - gap:4px; - flex:1 0 0; - } - -:is(:is(:is(:is(:is(#addSignatureDialog .mainContainer) #addSignatureActionContainer) #addSignatureControls) #horizontalContainer) #addSignatureDescriptionContainer):has(input:disabled) > label{ - opacity:0.4; - } - -:is(:is(:is(:is(:is(#addSignatureDialog .mainContainer) #addSignatureActionContainer) #addSignatureControls) #horizontalContainer) #addSignatureDescriptionContainer) > label{ - width:auto; - } - -:is(:is(:is(:is(#addSignatureDialog .mainContainer) #addSignatureActionContainer) #addSignatureControls) #horizontalContainer) #clearSignatureButton{ - display:flex; - height:32px; - padding:4px 8px; - align-items:center; - background-color:var(--clear-signature-button-bg); - border-width:var(--clear-signature-button-border-width); - border-style:var(--clear-signature-button-border-style); - border-color:var(--clear-signature-button-border-color); - border-radius:4px; - } - -:is(:is(:is(:is(:is(#addSignatureDialog .mainContainer) #addSignatureActionContainer) #addSignatureControls) #horizontalContainer) #clearSignatureButton) > span{ - display:flex; - height:24px; - align-items:center; - gap:4px; - flex-shrink:0; - - color:var(--clear-signature-button-color); - } - -:is(:is(:is(:is(:is(:is(#addSignatureDialog .mainContainer) #addSignatureActionContainer) #addSignatureControls) #horizontalContainer) #clearSignatureButton) > span)::after{ - content:""; - display:inline-block; - width:16px; - height:16px; - -webkit-mask-image:var(--clear-signature-button-icon); - mask-image:var(--clear-signature-button-icon); - -webkit-mask-size:cover; - mask-size:cover; - background-color:var(--clear-signature-button-color); - flex-shrink:0; - } - -:is(:is(:is(:is(:is(#addSignatureDialog .mainContainer) #addSignatureActionContainer) #addSignatureControls) #horizontalContainer) #clearSignatureButton):hover{ - background-color:var(--clear-signature-button-bg-hover); - } - -:is(:is(:is(:is(:is(#addSignatureDialog .mainContainer) #addSignatureActionContainer) #addSignatureControls) #horizontalContainer) #clearSignatureButton):hover > span{ - color:var(--clear-signature-button-hover-color); - } - -:is(:is(:is(:is(:is(:is(#addSignatureDialog .mainContainer) #addSignatureActionContainer) #addSignatureControls) #horizontalContainer) #clearSignatureButton):hover > span)::after{ - background-color:var(--clear-signature-button-hover-color); - } - -:is(:is(:is(:is(:is(#addSignatureDialog .mainContainer) #addSignatureActionContainer) #addSignatureControls) #horizontalContainer) #clearSignatureButton):active{ - background-color:var(--clear-signature-button-bg-active); - } - -:is(:is(:is(:is(:is(#addSignatureDialog .mainContainer) #addSignatureActionContainer) #addSignatureControls) #horizontalContainer) #clearSignatureButton):active > span{ - color:var(--clear-signature-button-active-color); - } - -:is(:is(:is(:is(:is(:is(#addSignatureDialog .mainContainer) #addSignatureActionContainer) #addSignatureControls) #horizontalContainer) #clearSignatureButton):active > span)::after{ - background-color:var(--clear-signature-button-active-color); - } - -:is(:is(:is(:is(:is(#addSignatureDialog .mainContainer) #addSignatureActionContainer) #addSignatureControls) #horizontalContainer) #clearSignatureButton):focus-visible{ - background-color:var(--clear-signature-button-bg-focus); - } - -:is(:is(:is(:is(:is(#addSignatureDialog .mainContainer) #addSignatureActionContainer) #addSignatureControls) #horizontalContainer) #clearSignatureButton):focus-visible > span{ - color:var(--clear-signature-button-focus-color); - } - -:is(:is(:is(:is(:is(:is(#addSignatureDialog .mainContainer) #addSignatureActionContainer) #addSignatureControls) #horizontalContainer) #clearSignatureButton):focus-visible > span)::after{ - background-color:var(--clear-signature-button-focus-color); - } - -:is(:is(:is(:is(:is(#addSignatureDialog .mainContainer) #addSignatureActionContainer) #addSignatureControls) #horizontalContainer) #clearSignatureButton):disabled{ - background-color:var(--clear-signature-button-bg-disabled); - border-color:var(--clear-signature-button-border-disabled-color); - } - -:is(:is(:is(:is(:is(#addSignatureDialog .mainContainer) #addSignatureActionContainer) #addSignatureControls) #horizontalContainer) #clearSignatureButton):disabled > span{ - color:var(--clear-signature-button-disabled-color); - } - -:is(:is(:is(:is(:is(:is(#addSignatureDialog .mainContainer) #addSignatureActionContainer) #addSignatureControls) #horizontalContainer) #clearSignatureButton):disabled > span)::after{ - background-color:var( - --clear-signature-button-disabled-color - ); - } - -:is(:is(:is(#addSignatureDialog .mainContainer) #addSignatureActionContainer) #addSignatureControls) #addSignatureSaveContainer{ - display:grid; - grid-template-columns:max-content auto; - gap:4px; - width:100%; - } - -:is(:is(:is(:is(#addSignatureDialog .mainContainer) #addSignatureActionContainer) #addSignatureControls) #addSignatureSaveContainer) > input{ - margin:0; - } - -:is(:is(:is(:is(:is(#addSignatureDialog .mainContainer) #addSignatureActionContainer) #addSignatureControls) #addSignatureSaveContainer) > input):disabled + label{ - opacity:0.4; - } - -:is(:is(:is(:is(#addSignatureDialog .mainContainer) #addSignatureActionContainer) #addSignatureControls) #addSignatureSaveContainer) > label{ - -webkit-user-select:none; - -moz-user-select:none; - user-select:none; - } - -:is(:is(:is(:is(#addSignatureDialog .mainContainer) #addSignatureActionContainer) #addSignatureControls) #addSignatureSaveContainer):not(.fullStorage) #addSignatureSaveWarning{ - display:none; - } - -.fullStorage:is(:is(:is(:is(#addSignatureDialog .mainContainer) #addSignatureActionContainer) #addSignatureControls) #addSignatureSaveContainer) #addSignatureSaveWarning{ - display:block; - opacity:1; - color:var(--save-warning-color); - font-size:11px; - } - -#editSignatureDescriptionDialog .mainContainer{ - padding-inline:16px; - box-sizing:border-box; - } - -:is(#editSignatureDescriptionDialog .mainContainer) .title{ - margin-inline-start:0; - } - -:is(#editSignatureDescriptionDialog .mainContainer) #editSignatureDescriptionAndView{ - width:auto; - display:flex; - justify-content:flex-end; - align-items:flex-start; - gap:12px; - align-self:stretch; - } - -:is(:is(#editSignatureDescriptionDialog .mainContainer) #editSignatureDescriptionAndView) #editSignatureDescriptionContainer{ - display:flex; - flex-direction:column; - align-items:flex-start; - gap:4px; - flex:1 1 auto; - } - -:is(:is(#editSignatureDescriptionDialog .mainContainer) #editSignatureDescriptionAndView) > svg{ - width:210px; - height:180px; - padding:8px; - background-color:var(--signature-bg); - } - -:is(:is(:is(#editSignatureDescriptionDialog .mainContainer) #editSignatureDescriptionAndView) > svg) > path{ - stroke:var(--button-signature-color); - stroke-width:1px; - stroke-linecap:round; - stroke-linejoin:round; - stroke-miterlimit:10; - vector-effect:non-scaling-stroke; - fill:none; - } - -.contours:is(:is(:is(:is(#editSignatureDescriptionDialog .mainContainer) #editSignatureDescriptionAndView) > svg) > path){ - fill:var(--button-signature-color); - stroke-width:0.5px; - } - -#editorSignatureParamsToolbar{ - padding:8px; +#signatureTypeControls { + position: absolute; + inset-inline: 8px; + inset-block-start: 8px; + display: flex; + align-items: center; + gap: 8px; + padding: 4px 8px; + background-color: rgba(0, 0, 0, 0.4); + border-radius: 4px; + z-index: 1; +} +#signatureTypeControls label { + font-size: 11px; + color: var(--secondary-text-color); +} +#signatureFontSelect { + font-size: 11px; +} +#signatureColorPicker { + width: 20px; + height: 20px; + padding: 0; + border: none; + background: transparent; } -#editorSignatureParamsToolbar #addSignatureDoorHanger{ - gap:8px; - padding:2px; - } +:is( + #addSignatureTypeContainer:is( + :is(:is(#addSignatureDialog .mainContainer) #addSignatureActionContainer) + > [role='tabpanel'] + ) + #addSignatureTypeInput +)::-moz-placeholder { + color: var(--signature-placeholder-color); + text-align: center; -:is(#editorSignatureParamsToolbar #addSignatureDoorHanger) .toolbarAddSignatureButtonContainer{ - height:32px; - display:flex; - justify-content:space-between; - align-items:center; - align-self:stretch; - gap:8px; - } - -:is(:is(#editorSignatureParamsToolbar #addSignatureDoorHanger) .toolbarAddSignatureButtonContainer) button{ - border:var(--button-signature-border); - border-radius:4px; - background-color:var(--button-signature-bg); - color:var(--button-signature-color); - } - -:is(:is(:is(#editorSignatureParamsToolbar #addSignatureDoorHanger) .toolbarAddSignatureButtonContainer) button):hover{ - background-color:var(--button-signature-hover-bg); - } - -:is(:is(:is(#editorSignatureParamsToolbar #addSignatureDoorHanger) .toolbarAddSignatureButtonContainer) button):active{ - border:var(--button-signature-active-border); - background-color:var(--button-signature-active-bg); - color:var(--button-signature-active-color); - } - -:is(:is(:is(#editorSignatureParamsToolbar #addSignatureDoorHanger) .toolbarAddSignatureButtonContainer) button):active::before{ - background-color:var(--button-signature-active-color); - } - -:is(:is(:is(#editorSignatureParamsToolbar #addSignatureDoorHanger) .toolbarAddSignatureButtonContainer) button):focus-visible{ - outline:var(--focus-ring-outline); - } - -:is(:is(:is(#editorSignatureParamsToolbar #addSignatureDoorHanger) .toolbarAddSignatureButtonContainer) button):focus-visible::before{ - background-color:var(--button-signature-color); - } - -:is(:is(:is(#editorSignatureParamsToolbar #addSignatureDoorHanger) .toolbarAddSignatureButtonContainer) .deleteButton)::before{ - -webkit-mask-image:var(--clear-signature-button-icon); - mask-image:var(--clear-signature-button-icon); - } - -:is(:is(#editorSignatureParamsToolbar #addSignatureDoorHanger) .toolbarAddSignatureButtonContainer) .toolbarAddSignatureButton{ - width:calc(0.8 * var(--editor-toolbar-min-width)); - height:100%; - min-height:var(--menuitem-height); - aspect-ratio:unset; - display:flex; - align-items:center; - justify-content:flex-start; - outline:none; - border-radius:4px; - box-sizing:border-box; - font:message-box; - position:relative; - flex:1 1 auto; - padding:0; - gap:8px; - text-align:start; - white-space:normal; - cursor:default; - overflow:hidden; - } - -:is(:is(:is(#editorSignatureParamsToolbar #addSignatureDoorHanger) .toolbarAddSignatureButtonContainer) .toolbarAddSignatureButton) > svg{ - display:inline-block; - height:100%; - aspect-ratio:1; - background-color:var(--signature-bg); - flex:none; - padding:4px; - box-sizing:border-box; - border:none; - border-radius:4px; - } - -:is(:is(:is(:is(#editorSignatureParamsToolbar #addSignatureDoorHanger) .toolbarAddSignatureButtonContainer) .toolbarAddSignatureButton) > svg) > path{ - stroke:var(--button-signature-color); - stroke-width:1px; - stroke-linecap:round; - stroke-linejoin:round; - stroke-miterlimit:10; - vector-effect:non-scaling-stroke; - fill:none; - } - -.contours:is(:is(:is(:is(:is(#editorSignatureParamsToolbar #addSignatureDoorHanger) .toolbarAddSignatureButtonContainer) .toolbarAddSignatureButton) > svg) > path){ - fill:var(--button-signature-color); - stroke-width:0.5px; - } - -:is(:is(:is(#editorSignatureParamsToolbar #addSignatureDoorHanger) .toolbarAddSignatureButtonContainer) .toolbarAddSignatureButton):is(:hover,:active) > svg{ - border-radius:4px 0 0 4px; - background-color:var(--signature-hover-bg); - } - -:is(:is(:is(#editorSignatureParamsToolbar #addSignatureDoorHanger) .toolbarAddSignatureButtonContainer) .toolbarAddSignatureButton):hover > span{ - color:var(--button-signature-hover-color); - } - -:is(:is(:is(#editorSignatureParamsToolbar #addSignatureDoorHanger) .toolbarAddSignatureButtonContainer) .toolbarAddSignatureButton):active{ - background-color:var(--button-signature-active-bg); - } - -:is(:is(:is(#editorSignatureParamsToolbar #addSignatureDoorHanger) .toolbarAddSignatureButtonContainer) .toolbarAddSignatureButton):is([disabled="disabled"],[disabled]){ - opacity:0.5; - pointer-events:none; - } - -:is(:is(:is(#editorSignatureParamsToolbar #addSignatureDoorHanger) .toolbarAddSignatureButtonContainer) .toolbarAddSignatureButton) > span{ - height:auto; - text-overflow:ellipsis; - white-space:nowrap; - flex:1 1 auto; - font:menu; - font-size:13px; - font-style:normal; - font-weight:400; - line-height:normal; - overflow:hidden; - } - -.editDescription.altText{ - --alt-text-add-image:url(images/editor-toolbar-edit.svg) !important; + font: menu; + font-style: normal; + font-weight: 274; + font-size: 44px; + line-height: normal; } -.editDescription.altText::before{ - width:16px !important; - height:16px !important; - } +:is( + #addSignatureTypeContainer:is( + :is(:is(#addSignatureDialog .mainContainer) #addSignatureActionContainer) + > [role='tabpanel'] + ) + #addSignatureTypeInput +)::placeholder { + color: var(--signature-placeholder-color); + text-align: center; + + font: menu; + font-style: normal; + font-weight: 274; + font-size: 44px; + line-height: normal; +} + +#addSignatureDrawContainer:is( + :is(:is(#addSignatureDialog .mainContainer) #addSignatureActionContainer) + > [role='tabpanel'] +) { + display: none; +} + +#addSignatureDrawContainer:is( + :is(:is(#addSignatureDialog .mainContainer) #addSignatureActionContainer) + > [role='tabpanel'] + ) + > span { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + display: grid; + align-items: center; + justify-content: center; + + background-color: transparent; + color: var(--signature-placeholder-color); + -webkit-user-select: none; + -moz-user-select: none; + user-select: none; + touch-action: none; +} + +#addSignatureDrawContainer:is( + :is(:is(#addSignatureDialog .mainContainer) #addSignatureActionContainer) + > [role='tabpanel'] + ) + > svg { + stroke: var(--signature-color); + fill: none; + stroke-opacity: 1; + stroke-linecap: round; + stroke-linejoin: round; + stroke-miterlimit: 10; + touch-action: none; +} + +:is( + #addSignatureDrawContainer:is( + :is(:is(#addSignatureDialog .mainContainer) #addSignatureActionContainer) + > [role='tabpanel'] + ) + > svg +):hover { + cursor: var(--draw-cursor); +} + +#addSignatureDrawContainer:is( + :is(:is(#addSignatureDialog .mainContainer) #addSignatureActionContainer) + > [role='tabpanel'] + ) + #thickness { + position: absolute; + width: 100%; + inset-block-end: 0; + display: grid; + align-items: center; + justify-content: center; + pointer-events: none; +} + +:is( + #addSignatureDrawContainer:is( + :is( + :is(#addSignatureDialog .mainContainer) #addSignatureActionContainer + ) + > [role='tabpanel'] + ) + #thickness + ) + > span { + color: var(--signature-draw-placeholder-color); +} + +:is( + #addSignatureDrawContainer:is( + :is( + :is(#addSignatureDialog .mainContainer) #addSignatureActionContainer + ) + > [role='tabpanel'] + ) + #thickness + ) + > div { + width: auto; + height: auto; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 6px 8px 7px; + margin: 0; + background-color: var(--thickness-bg); + border-radius: 4px 4px 0 0; + border-inline: var(--thickness-border); + border-top: var(--thickness-border); + pointer-events: auto; + position: relative; + top: 1px; +} + +:is( + :is( + #addSignatureDrawContainer:is( + :is( + :is(#addSignatureDialog .mainContainer) + #addSignatureActionContainer + ) + > [role='tabpanel'] + ) + #thickness + ) + > div + ) + > label { + color: var(--thickness-label-color); +} + +:is( + :is( + #addSignatureDrawContainer:is( + :is( + :is(#addSignatureDialog .mainContainer) + #addSignatureActionContainer + ) + > [role='tabpanel'] + ) + #thickness + ) + > div + ) + > input { + width: 100px; + height: 14px; + background-color: transparent; +} + +:is( + :is( + :is( + #addSignatureDrawContainer:is( + :is( + :is(#addSignatureDialog .mainContainer) + #addSignatureActionContainer + ) + > [role='tabpanel'] + ) + #thickness + ) + > div + ) + > input +)::-webkit-slider-runnable-track, +:is( + :is( + :is( + #addSignatureDrawContainer:is( + :is( + :is(#addSignatureDialog .mainContainer) + #addSignatureActionContainer + ) + > [role='tabpanel'] + ) + #thickness + ) + > div + ) + > input +)::-moz-range-track, +:is( + :is( + :is( + #addSignatureDrawContainer:is( + :is( + :is(#addSignatureDialog .mainContainer) + #addSignatureActionContainer + ) + > [role='tabpanel'] + ) + #thickness + ) + > div + ) + > input +)::-moz-range-progress { + background-color: var(--thickness-slider-color); +} + +:is( + :is( + :is( + #addSignatureDrawContainer:is( + :is( + :is(#addSignatureDialog .mainContainer) + #addSignatureActionContainer + ) + > [role='tabpanel'] + ) + #thickness + ) + > div + ) + > input +)::-webkit-slider-thumb, +:is( + :is( + :is( + #addSignatureDrawContainer:is( + :is( + :is(#addSignatureDialog .mainContainer) + #addSignatureActionContainer + ) + > [role='tabpanel'] + ) + #thickness + ) + > div + ) + > input +)::-moz-range-thumb { + background-color: var(--thickness-bg); +} + +:is( + :is( + #addSignatureDrawContainer:is( + :is( + :is(#addSignatureDialog .mainContainer) + #addSignatureActionContainer + ) + > [role='tabpanel'] + ) + #thickness + ) + > div + ) + > input { + border-radius: 4.5px; + border: 0; + color: var(--signature-color); +} + +#addSignatureImageContainer:is( + :is(:is(#addSignatureDialog .mainContainer) #addSignatureActionContainer) + > [role='tabpanel'] +) { + display: none; +} + +#addSignatureImageContainer:is( + :is(:is(#addSignatureDialog .mainContainer) #addSignatureActionContainer) + > [role='tabpanel'] + ) + > svg { + stroke: none; + stroke-width: 0; + fill: var(--signature-color); + fill-opacity: 1; +} + +#addSignatureImageContainer:is( + :is(:is(#addSignatureDialog .mainContainer) #addSignatureActionContainer) + > [role='tabpanel'] + ) + #addSignatureImagePlaceholder { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: transparent; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} + +:is( + #addSignatureImageContainer:is( + :is( + :is(#addSignatureDialog .mainContainer) #addSignatureActionContainer + ) + > [role='tabpanel'] + ) + #addSignatureImagePlaceholder + ) + span { + color: var(--signature-placeholder-color); +} + +:is( + #addSignatureImageContainer:is( + :is( + :is(#addSignatureDialog .mainContainer) #addSignatureActionContainer + ) + > [role='tabpanel'] + ) + #addSignatureImagePlaceholder + ) + a { + color: var(--open-link-fg); + text-decoration: underline; + cursor: pointer; +} + +:is( + :is( + #addSignatureImageContainer:is( + :is( + :is(#addSignatureDialog .mainContainer) + #addSignatureActionContainer + ) + > [role='tabpanel'] + ) + #addSignatureImagePlaceholder + ) + a +):hover { + color: var(--open-link-hover-fg); +} + +#addSignatureImageContainer:is( + :is(:is(#addSignatureDialog .mainContainer) #addSignatureActionContainer) + > [role='tabpanel'] + ) + #addSignatureFilePicker { + visibility: hidden; + position: relative; + width: 0; + height: 0; +} + +[data-selected='type']:is( + :is(#addSignatureDialog .mainContainer) #addSignatureActionContainer + ) + > #addSignatureTypeContainer, +[data-selected='draw']:is( + :is(#addSignatureDialog .mainContainer) #addSignatureActionContainer + ) + > #addSignatureDrawContainer, +[data-selected='image']:is( + :is(#addSignatureDialog .mainContainer) #addSignatureActionContainer + ) + > #addSignatureImageContainer { + display: block; +} + +:is(:is(#addSignatureDialog .mainContainer) #addSignatureActionContainer) + #addSignatureControls { + display: flex; + flex-direction: column; + justify-content: center; + align-items: flex-start; + gap: 12px; + align-self: stretch; +} + +:is( + :is(:is(#addSignatureDialog .mainContainer) #addSignatureActionContainer) + #addSignatureControls + ) + #horizontalContainer { + display: flex; + align-items: flex-end; + gap: 16px; + align-self: stretch; +} + +:is( + :is( + :is( + :is(#addSignatureDialog .mainContainer) #addSignatureActionContainer + ) + #addSignatureControls + ) + #horizontalContainer + ) + #addSignatureDescriptionContainer { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 4px; + flex: 1 0 0; +} + +:is( + :is( + :is( + :is( + :is(#addSignatureDialog .mainContainer) + #addSignatureActionContainer + ) + #addSignatureControls + ) + #horizontalContainer + ) + #addSignatureDescriptionContainer + ):has(input:disabled) + > label { + opacity: 0.4; +} + +:is( + :is( + :is( + :is( + :is(#addSignatureDialog .mainContainer) + #addSignatureActionContainer + ) + #addSignatureControls + ) + #horizontalContainer + ) + #addSignatureDescriptionContainer + ) + > label { + width: auto; +} + +:is( + :is( + :is( + :is(#addSignatureDialog .mainContainer) #addSignatureActionContainer + ) + #addSignatureControls + ) + #horizontalContainer + ) + #clearSignatureButton { + display: flex; + height: 32px; + padding: 4px 8px; + align-items: center; + background-color: var(--clear-signature-button-bg); + border-width: var(--clear-signature-button-border-width); + border-style: var(--clear-signature-button-border-style); + border-color: var(--clear-signature-button-border-color); + border-radius: 4px; +} + +:is( + :is( + :is( + :is( + :is(#addSignatureDialog .mainContainer) + #addSignatureActionContainer + ) + #addSignatureControls + ) + #horizontalContainer + ) + #clearSignatureButton + ) + > span { + display: flex; + height: 24px; + align-items: center; + gap: 4px; + flex-shrink: 0; + + color: var(--clear-signature-button-color); +} + +:is( + :is( + :is( + :is( + :is( + :is(#addSignatureDialog .mainContainer) + #addSignatureActionContainer + ) + #addSignatureControls + ) + #horizontalContainer + ) + #clearSignatureButton + ) + > span +)::after { + content: ''; + display: inline-block; + width: 16px; + height: 16px; + -webkit-mask-image: var(--clear-signature-button-icon); + mask-image: var(--clear-signature-button-icon); + -webkit-mask-size: cover; + mask-size: cover; + background-color: var(--clear-signature-button-color); + flex-shrink: 0; +} + +:is( + :is( + :is( + :is( + :is(#addSignatureDialog .mainContainer) + #addSignatureActionContainer + ) + #addSignatureControls + ) + #horizontalContainer + ) + #clearSignatureButton +):hover { + background-color: var(--clear-signature-button-bg-hover); +} + +:is( + :is( + :is( + :is( + :is(#addSignatureDialog .mainContainer) + #addSignatureActionContainer + ) + #addSignatureControls + ) + #horizontalContainer + ) + #clearSignatureButton + ):hover + > span { + color: var(--clear-signature-button-hover-color); +} + +:is( + :is( + :is( + :is( + :is( + :is(#addSignatureDialog .mainContainer) + #addSignatureActionContainer + ) + #addSignatureControls + ) + #horizontalContainer + ) + #clearSignatureButton + ):hover + > span +)::after { + background-color: var(--clear-signature-button-hover-color); +} + +:is( + :is( + :is( + :is( + :is(#addSignatureDialog .mainContainer) + #addSignatureActionContainer + ) + #addSignatureControls + ) + #horizontalContainer + ) + #clearSignatureButton +):active { + background-color: var(--clear-signature-button-bg-active); +} + +:is( + :is( + :is( + :is( + :is(#addSignatureDialog .mainContainer) + #addSignatureActionContainer + ) + #addSignatureControls + ) + #horizontalContainer + ) + #clearSignatureButton + ):active + > span { + color: var(--clear-signature-button-active-color); +} + +:is( + :is( + :is( + :is( + :is( + :is(#addSignatureDialog .mainContainer) + #addSignatureActionContainer + ) + #addSignatureControls + ) + #horizontalContainer + ) + #clearSignatureButton + ):active + > span +)::after { + background-color: var(--clear-signature-button-active-color); +} + +:is( + :is( + :is( + :is( + :is(#addSignatureDialog .mainContainer) + #addSignatureActionContainer + ) + #addSignatureControls + ) + #horizontalContainer + ) + #clearSignatureButton +):focus-visible { + background-color: var(--clear-signature-button-bg-focus); +} + +:is( + :is( + :is( + :is( + :is(#addSignatureDialog .mainContainer) + #addSignatureActionContainer + ) + #addSignatureControls + ) + #horizontalContainer + ) + #clearSignatureButton + ):focus-visible + > span { + color: var(--clear-signature-button-focus-color); +} + +:is( + :is( + :is( + :is( + :is( + :is(#addSignatureDialog .mainContainer) + #addSignatureActionContainer + ) + #addSignatureControls + ) + #horizontalContainer + ) + #clearSignatureButton + ):focus-visible + > span +)::after { + background-color: var(--clear-signature-button-focus-color); +} + +:is( + :is( + :is( + :is( + :is(#addSignatureDialog .mainContainer) + #addSignatureActionContainer + ) + #addSignatureControls + ) + #horizontalContainer + ) + #clearSignatureButton +):disabled { + background-color: var(--clear-signature-button-bg-disabled); + border-color: var(--clear-signature-button-border-disabled-color); +} + +:is( + :is( + :is( + :is( + :is(#addSignatureDialog .mainContainer) + #addSignatureActionContainer + ) + #addSignatureControls + ) + #horizontalContainer + ) + #clearSignatureButton + ):disabled + > span { + color: var(--clear-signature-button-disabled-color); +} + +:is( + :is( + :is( + :is( + :is( + :is(#addSignatureDialog .mainContainer) + #addSignatureActionContainer + ) + #addSignatureControls + ) + #horizontalContainer + ) + #clearSignatureButton + ):disabled + > span +)::after { + background-color: var(--clear-signature-button-disabled-color); +} + +:is( + :is(:is(#addSignatureDialog .mainContainer) #addSignatureActionContainer) + #addSignatureControls + ) + #addSignatureSaveContainer { + display: grid; + grid-template-columns: max-content auto; + gap: 4px; + width: 100%; +} + +:is( + :is( + :is( + :is(#addSignatureDialog .mainContainer) #addSignatureActionContainer + ) + #addSignatureControls + ) + #addSignatureSaveContainer + ) + > input { + margin: 0; +} + +:is( + :is( + :is( + :is( + :is(#addSignatureDialog .mainContainer) + #addSignatureActionContainer + ) + #addSignatureControls + ) + #addSignatureSaveContainer + ) + > input + ):disabled + + label { + opacity: 0.4; +} + +:is( + :is( + :is( + :is(#addSignatureDialog .mainContainer) #addSignatureActionContainer + ) + #addSignatureControls + ) + #addSignatureSaveContainer + ) + > label { + -webkit-user-select: none; + -moz-user-select: none; + user-select: none; +} + +:is( + :is( + :is( + :is(#addSignatureDialog .mainContainer) #addSignatureActionContainer + ) + #addSignatureControls + ) + #addSignatureSaveContainer + ):not(.fullStorage) + #addSignatureSaveWarning { + display: none; +} + +.fullStorage:is( + :is( + :is( + :is(#addSignatureDialog .mainContainer) #addSignatureActionContainer + ) + #addSignatureControls + ) + #addSignatureSaveContainer + ) + #addSignatureSaveWarning { + display: block; + opacity: 1; + color: var(--save-warning-color); + font-size: 11px; +} + +#editSignatureDescriptionDialog .mainContainer { + padding-inline: 16px; + box-sizing: border-box; +} + +:is(#editSignatureDescriptionDialog .mainContainer) .title { + margin-inline-start: 0; +} + +:is(#editSignatureDescriptionDialog .mainContainer) + #editSignatureDescriptionAndView { + width: auto; + display: flex; + justify-content: flex-end; + align-items: flex-start; + gap: 12px; + align-self: stretch; +} + +:is( + :is(#editSignatureDescriptionDialog .mainContainer) + #editSignatureDescriptionAndView + ) + #editSignatureDescriptionContainer { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 4px; + flex: 1 1 auto; +} + +:is( + :is(#editSignatureDescriptionDialog .mainContainer) + #editSignatureDescriptionAndView + ) + > svg { + width: 210px; + height: 180px; + padding: 8px; + background-color: var(--signature-bg); +} + +:is( + :is( + :is(#editSignatureDescriptionDialog .mainContainer) + #editSignatureDescriptionAndView + ) + > svg + ) + > path { + stroke: var(--button-signature-color); + stroke-width: 1px; + stroke-linecap: round; + stroke-linejoin: round; + stroke-miterlimit: 10; + vector-effect: non-scaling-stroke; + fill: none; +} + +.contours:is( + :is( + :is( + :is(#editSignatureDescriptionDialog .mainContainer) + #editSignatureDescriptionAndView + ) + > svg + ) + > path +) { + fill: var(--button-signature-color); + stroke-width: 0.5px; +} + +#editorSignatureParamsToolbar { + padding: 8px; +} + +#editorSignatureParamsToolbar #addSignatureDoorHanger { + gap: 8px; + padding: 2px; +} + +:is(#editorSignatureParamsToolbar #addSignatureDoorHanger) + .toolbarAddSignatureButtonContainer { + height: 32px; + display: flex; + justify-content: space-between; + align-items: center; + align-self: stretch; + gap: 8px; +} + +:is( + :is(#editorSignatureParamsToolbar #addSignatureDoorHanger) + .toolbarAddSignatureButtonContainer + ) + button { + border: var(--button-signature-border); + border-radius: 4px; + background-color: var(--button-signature-bg); + color: var(--button-signature-color); +} + +:is( + :is( + :is(#editorSignatureParamsToolbar #addSignatureDoorHanger) + .toolbarAddSignatureButtonContainer + ) + button +):hover { + background-color: var(--button-signature-hover-bg); +} + +:is( + :is( + :is(#editorSignatureParamsToolbar #addSignatureDoorHanger) + .toolbarAddSignatureButtonContainer + ) + button +):active { + border: var(--button-signature-active-border); + background-color: var(--button-signature-active-bg); + color: var(--button-signature-active-color); +} + +:is( + :is( + :is(#editorSignatureParamsToolbar #addSignatureDoorHanger) + .toolbarAddSignatureButtonContainer + ) + button + ):active::before { + background-color: var(--button-signature-active-color); +} + +:is( + :is( + :is(#editorSignatureParamsToolbar #addSignatureDoorHanger) + .toolbarAddSignatureButtonContainer + ) + button +):focus-visible { + outline: var(--focus-ring-outline); +} + +:is( + :is( + :is(#editorSignatureParamsToolbar #addSignatureDoorHanger) + .toolbarAddSignatureButtonContainer + ) + button + ):focus-visible::before { + background-color: var(--button-signature-color); +} + +:is( + :is( + :is(#editorSignatureParamsToolbar #addSignatureDoorHanger) + .toolbarAddSignatureButtonContainer + ) + .deleteButton +)::before { + -webkit-mask-image: var(--clear-signature-button-icon); + mask-image: var(--clear-signature-button-icon); +} + +:is( + :is(#editorSignatureParamsToolbar #addSignatureDoorHanger) + .toolbarAddSignatureButtonContainer + ) + .toolbarAddSignatureButton { + width: calc(0.8 * var(--editor-toolbar-min-width)); + height: 100%; + min-height: var(--menuitem-height); + aspect-ratio: unset; + display: flex; + align-items: center; + justify-content: flex-start; + outline: none; + border-radius: 4px; + box-sizing: border-box; + font: message-box; + position: relative; + flex: 1 1 auto; + padding: 0; + gap: 8px; + text-align: start; + white-space: normal; + cursor: default; + overflow: hidden; +} + +:is( + :is( + :is(#editorSignatureParamsToolbar #addSignatureDoorHanger) + .toolbarAddSignatureButtonContainer + ) + .toolbarAddSignatureButton + ) + > svg { + display: inline-block; + height: 100%; + aspect-ratio: 1; + background-color: var(--signature-bg); + flex: none; + padding: 4px; + box-sizing: border-box; + border: none; + border-radius: 4px; +} + +:is( + :is( + :is( + :is(#editorSignatureParamsToolbar #addSignatureDoorHanger) + .toolbarAddSignatureButtonContainer + ) + .toolbarAddSignatureButton + ) + > svg + ) + > path { + stroke: var(--button-signature-color); + stroke-width: 1px; + stroke-linecap: round; + stroke-linejoin: round; + stroke-miterlimit: 10; + vector-effect: non-scaling-stroke; + fill: none; +} + +.contours:is( + :is( + :is( + :is( + :is(#editorSignatureParamsToolbar #addSignatureDoorHanger) + .toolbarAddSignatureButtonContainer + ) + .toolbarAddSignatureButton + ) + > svg + ) + > path +) { + fill: var(--button-signature-color); + stroke-width: 0.5px; +} + +:is( + :is( + :is(#editorSignatureParamsToolbar #addSignatureDoorHanger) + .toolbarAddSignatureButtonContainer + ) + .toolbarAddSignatureButton + ):is(:hover, :active) + > svg { + border-radius: 4px 0 0 4px; + background-color: var(--signature-hover-bg); +} + +:is( + :is( + :is(#editorSignatureParamsToolbar #addSignatureDoorHanger) + .toolbarAddSignatureButtonContainer + ) + .toolbarAddSignatureButton + ):hover + > span { + color: var(--button-signature-hover-color); +} + +:is( + :is( + :is(#editorSignatureParamsToolbar #addSignatureDoorHanger) + .toolbarAddSignatureButtonContainer + ) + .toolbarAddSignatureButton +):active { + background-color: var(--button-signature-active-bg); +} + +:is( + :is( + :is(#editorSignatureParamsToolbar #addSignatureDoorHanger) + .toolbarAddSignatureButtonContainer + ) + .toolbarAddSignatureButton +):is([disabled='disabled'], [disabled]) { + opacity: 0.5; + pointer-events: none; +} + +:is( + :is( + :is(#editorSignatureParamsToolbar #addSignatureDoorHanger) + .toolbarAddSignatureButtonContainer + ) + .toolbarAddSignatureButton + ) + > span { + height: auto; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1 1 auto; + font: menu; + font-size: 13px; + font-style: normal; + font-weight: 400; + line-height: normal; + overflow: hidden; +} + +.editDescription.altText { + --alt-text-add-image: url(images/editor-toolbar-edit.svg) !important; +} + +.editDescription.altText::before { + width: 16px !important; + height: 16px !important; +} .commentPopup, -#commentManagerDialog{ - width:360px; - max-width:100%; - min-width:200px; - position:absolute; - padding:8px 16px 16px; - margin-left:0; - margin-top:0; - box-sizing:border-box; +#commentManagerDialog { + width: 360px; + max-width: 100%; + min-width: 200px; + position: absolute; + padding: 8px 16px 16px; + margin-left: 0; + margin-top: 0; + box-sizing: border-box; - border-radius:8px; + border-radius: 8px; } -#commentManagerDialog{ - --comment-close-button-icon:url(images/comment-closeButton.svg); +#commentManagerDialog { + --comment-close-button-icon: url(images/comment-closeButton.svg); } -#commentManagerDialog .mainContainer{ - width:100%; - height:auto; - display:flex; - flex-direction:column; - align-items:flex-start; - gap:4px; - } - -:is(#commentManagerDialog .mainContainer) #commentManagerToolbar{ - width:100%; - height:32px; - display:flex; - justify-content:flex-start; - align-items:flex-start; - gap:8px; - align-self:stretch; - - cursor:move; - } - -:is(#commentManagerDialog .mainContainer) #commentManagerTextInput{ - width:100%; - min-height:132px; - margin-bottom:12px; - } - -.annotationLayer.disabled :is(.annotationCommentButton){ - display:none; +#commentManagerDialog .mainContainer { + width: 100%; + height: auto; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 4px; } -:is(.annotationLayer,.annotationEditorLayer) .annotationCommentButton{ - --csstools-color-scheme--light:initial; - color-scheme:light dark; - --csstools-light-dark-toggle--75:var(--csstools-color-scheme--light) #1c1b22; - --comment-button-bg:var(--csstools-light-dark-toggle--75, white); - --csstools-light-dark-toggle--76:var(--csstools-color-scheme--light) #fbfbfe; - --comment-button-fg:var(--csstools-light-dark-toggle--76, #5b5b66); - --csstools-light-dark-toggle--77:var(--csstools-color-scheme--light) #a6ecf4; - --comment-button-active-bg:var(--csstools-light-dark-toggle--77, #0041a4); - --csstools-light-dark-toggle--78:var(--csstools-color-scheme--light) #15141a; - --comment-button-active-fg:var(--csstools-light-dark-toggle--78, white); - --csstools-light-dark-toggle--79:var(--csstools-color-scheme--light) #61dce9; - --comment-button-hover-bg:var(--csstools-light-dark-toggle--79, #0053cb); - --csstools-light-dark-toggle--80:var(--csstools-color-scheme--light) #15141a; - --comment-button-hover-fg:var(--csstools-light-dark-toggle--80, white); - --csstools-light-dark-toggle--81:var(--csstools-color-scheme--light) #00cadb; - --comment-button-selected-bg:var(--csstools-light-dark-toggle--81, #0062fa); - --csstools-light-dark-toggle--82:var(--csstools-color-scheme--light) #bfbfc9; - --comment-button-border-color:var(--csstools-light-dark-toggle--82, #8f8f9d); - --comment-button-active-border-color:var(--comment-button-active-bg); - --csstools-light-dark-toggle--83:var(--csstools-color-scheme--light) #3a3944; - --comment-button-focus-border-color:var(--csstools-light-dark-toggle--83, #cfcfd8); - --comment-button-hover-border-color:var(--comment-button-hover-bg); - --comment-button-selected-border-color:var(--comment-button-selected-bg); - --csstools-light-dark-toggle--84:var(--csstools-color-scheme--light) #15141a; - --comment-button-selected-fg:var(--csstools-light-dark-toggle--84, white); - --comment-button-dim:24px; - --csstools-light-dark-toggle--85:var(--csstools-color-scheme--light) rgb(0 0 0 / 0.2); - --csstools-light-dark-toggle--86:var(--csstools-color-scheme--light) rgb(0 0 0 / 0.4); - --comment-button-box-shadow:0 0.25px 0.75px 0 var(--csstools-light-dark-toggle--85, rgb(0 0 0 / 0.05)), 0 2px 6px 0 var(--csstools-light-dark-toggle--86, rgb(0 0 0 / 0.1)); - --csstools-light-dark-toggle--87:var(--csstools-color-scheme--light) #00cadb; - --comment-button-focus-outline-color:var(--csstools-light-dark-toggle--87, #0062fa); - } +:is(#commentManagerDialog .mainContainer) #commentManagerToolbar { + width: 100%; + height: 32px; + display: flex; + justify-content: flex-start; + align-items: flex-start; + gap: 8px; + align-self: stretch; -@supports (color: light-dark(red, red)){ -:is(.annotationLayer,.annotationEditorLayer) .annotationCommentButton{ - --comment-button-bg:light-dark(white, #1c1b22); - --comment-button-fg:light-dark(#5b5b66, #fbfbfe); - --comment-button-active-bg:light-dark(#0041a4, #a6ecf4); - --comment-button-active-fg:light-dark(white, #15141a); - --comment-button-hover-bg:light-dark(#0053cb, #61dce9); - --comment-button-hover-fg:light-dark(white, #15141a); - --comment-button-selected-bg:light-dark(#0062fa, #00cadb); - --comment-button-border-color:light-dark(#8f8f9d, #bfbfc9); - --comment-button-focus-border-color:light-dark(#cfcfd8, #3a3944); - --comment-button-selected-fg:light-dark(white, #15141a); + cursor: move; +} + +:is(#commentManagerDialog .mainContainer) #commentManagerTextInput { + width: 100%; + min-height: 132px; + margin-bottom: 12px; +} + +.annotationLayer.disabled :is(.annotationCommentButton) { + display: none; +} + +:is(.annotationLayer, .annotationEditorLayer) .annotationCommentButton { + --csstools-color-scheme--light: initial; + color-scheme: light dark; + --csstools-light-dark-toggle--75: var(--csstools-color-scheme--light) #1c1b22; + --comment-button-bg: var(--csstools-light-dark-toggle--75, white); + --csstools-light-dark-toggle--76: var(--csstools-color-scheme--light) #fbfbfe; + --comment-button-fg: var(--csstools-light-dark-toggle--76, #5b5b66); + --csstools-light-dark-toggle--77: var(--csstools-color-scheme--light) #a6ecf4; + --comment-button-active-bg: var(--csstools-light-dark-toggle--77, #0041a4); + --csstools-light-dark-toggle--78: var(--csstools-color-scheme--light) #15141a; + --comment-button-active-fg: var(--csstools-light-dark-toggle--78, white); + --csstools-light-dark-toggle--79: var(--csstools-color-scheme--light) #61dce9; + --comment-button-hover-bg: var(--csstools-light-dark-toggle--79, #0053cb); + --csstools-light-dark-toggle--80: var(--csstools-color-scheme--light) #15141a; + --comment-button-hover-fg: var(--csstools-light-dark-toggle--80, white); + --csstools-light-dark-toggle--81: var(--csstools-color-scheme--light) #00cadb; + --comment-button-selected-bg: var(--csstools-light-dark-toggle--81, #0062fa); + --csstools-light-dark-toggle--82: var(--csstools-color-scheme--light) #bfbfc9; + --comment-button-border-color: var(--csstools-light-dark-toggle--82, #8f8f9d); + --comment-button-active-border-color: var(--comment-button-active-bg); + --csstools-light-dark-toggle--83: var(--csstools-color-scheme--light) #3a3944; + --comment-button-focus-border-color: var( + --csstools-light-dark-toggle--83, + #cfcfd8 + ); + --comment-button-hover-border-color: var(--comment-button-hover-bg); + --comment-button-selected-border-color: var(--comment-button-selected-bg); + --csstools-light-dark-toggle--84: var(--csstools-color-scheme--light) #15141a; + --comment-button-selected-fg: var(--csstools-light-dark-toggle--84, white); + --comment-button-dim: 24px; + --csstools-light-dark-toggle--85: var(--csstools-color-scheme--light) + rgb(0 0 0 / 0.2); + --csstools-light-dark-toggle--86: var(--csstools-color-scheme--light) + rgb(0 0 0 / 0.4); + --comment-button-box-shadow: + 0 0.25px 0.75px 0 var(--csstools-light-dark-toggle--85, rgb(0 0 0 / 0.05)), + 0 2px 6px 0 var(--csstools-light-dark-toggle--86, rgb(0 0 0 / 0.1)); + --csstools-light-dark-toggle--87: var(--csstools-color-scheme--light) #00cadb; + --comment-button-focus-outline-color: var( + --csstools-light-dark-toggle--87, + #0062fa + ); +} + +@supports (color: light-dark(red, red)) { + :is(.annotationLayer, .annotationEditorLayer) .annotationCommentButton { + --comment-button-bg: light-dark(white, #1c1b22); + --comment-button-fg: light-dark(#5b5b66, #fbfbfe); + --comment-button-active-bg: light-dark(#0041a4, #a6ecf4); + --comment-button-active-fg: light-dark(white, #15141a); + --comment-button-hover-bg: light-dark(#0053cb, #61dce9); + --comment-button-hover-fg: light-dark(white, #15141a); + --comment-button-selected-bg: light-dark(#0062fa, #00cadb); + --comment-button-border-color: light-dark(#8f8f9d, #bfbfc9); + --comment-button-focus-border-color: light-dark(#cfcfd8, #3a3944); + --comment-button-selected-fg: light-dark(white, #15141a); } } -@supports (color: light-dark(red, red)) and (color: rgb(0 0 0 / 0)){ -:is(.annotationLayer,.annotationEditorLayer) .annotationCommentButton{ - --comment-button-box-shadow:0 0.25px 0.75px 0 light-dark(rgb(0 0 0 / 0.05), rgb(0 0 0 / 0.2)), 0 2px 6px 0 light-dark(rgb(0 0 0 / 0.1), rgb(0 0 0 / 0.4)); +@supports (color: light-dark(red, red)) and (color: rgb(0 0 0 / 0)) { + :is(.annotationLayer, .annotationEditorLayer) .annotationCommentButton { + --comment-button-box-shadow: + 0 0.25px 0.75px 0 light-dark(rgb(0 0 0 / 0.05), rgb(0 0 0 / 0.2)), + 0 2px 6px 0 light-dark(rgb(0 0 0 / 0.1), rgb(0 0 0 / 0.4)); } } -@supports (color: light-dark(red, red)){ -:is(.annotationLayer,.annotationEditorLayer) .annotationCommentButton{ - --comment-button-focus-outline-color:light-dark(#0062fa, #00cadb); +@supports (color: light-dark(red, red)) { + :is(.annotationLayer, .annotationEditorLayer) .annotationCommentButton { + --comment-button-focus-outline-color: light-dark(#0062fa, #00cadb); } } -@supports not (color: light-dark(tan, tan)){ - -:is(:is(.annotationLayer,.annotationEditorLayer) .annotationCommentButton) *{ - --csstools-light-dark-toggle--75:var(--csstools-color-scheme--light) #1c1b22; - --comment-button-bg:var(--csstools-light-dark-toggle--75, white); - --csstools-light-dark-toggle--76:var(--csstools-color-scheme--light) #fbfbfe; - --comment-button-fg:var(--csstools-light-dark-toggle--76, #5b5b66); - --csstools-light-dark-toggle--77:var(--csstools-color-scheme--light) #a6ecf4; - --comment-button-active-bg:var(--csstools-light-dark-toggle--77, #0041a4); - --csstools-light-dark-toggle--78:var(--csstools-color-scheme--light) #15141a; - --comment-button-active-fg:var(--csstools-light-dark-toggle--78, white); - --csstools-light-dark-toggle--79:var(--csstools-color-scheme--light) #61dce9; - --comment-button-hover-bg:var(--csstools-light-dark-toggle--79, #0053cb); - --csstools-light-dark-toggle--80:var(--csstools-color-scheme--light) #15141a; - --comment-button-hover-fg:var(--csstools-light-dark-toggle--80, white); - --csstools-light-dark-toggle--81:var(--csstools-color-scheme--light) #00cadb; - --comment-button-selected-bg:var(--csstools-light-dark-toggle--81, #0062fa); - --csstools-light-dark-toggle--82:var(--csstools-color-scheme--light) #bfbfc9; - --comment-button-border-color:var(--csstools-light-dark-toggle--82, #8f8f9d); - --csstools-light-dark-toggle--83:var(--csstools-color-scheme--light) #3a3944; - --comment-button-focus-border-color:var(--csstools-light-dark-toggle--83, #cfcfd8); - --csstools-light-dark-toggle--84:var(--csstools-color-scheme--light) #15141a; - --comment-button-selected-fg:var(--csstools-light-dark-toggle--84, white); - --csstools-light-dark-toggle--85:var(--csstools-color-scheme--light) rgb(0 0 0 / 0.2); - --csstools-light-dark-toggle--86:var(--csstools-color-scheme--light) rgb(0 0 0 / 0.4); - --comment-button-box-shadow:0 0.25px 0.75px 0 var(--csstools-light-dark-toggle--85, rgb(0 0 0 / 0.05)), 0 2px 6px 0 var(--csstools-light-dark-toggle--86, rgb(0 0 0 / 0.1)); - --csstools-light-dark-toggle--87:var(--csstools-color-scheme--light) #00cadb; - --comment-button-focus-outline-color:var(--csstools-light-dark-toggle--87, #0062fa); +@supports not (color: light-dark(tan, tan)) { + :is(:is(.annotationLayer, .annotationEditorLayer) .annotationCommentButton) + * { + --csstools-light-dark-toggle--75: var(--csstools-color-scheme--light) + #1c1b22; + --comment-button-bg: var(--csstools-light-dark-toggle--75, white); + --csstools-light-dark-toggle--76: var(--csstools-color-scheme--light) + #fbfbfe; + --comment-button-fg: var(--csstools-light-dark-toggle--76, #5b5b66); + --csstools-light-dark-toggle--77: var(--csstools-color-scheme--light) + #a6ecf4; + --comment-button-active-bg: var(--csstools-light-dark-toggle--77, #0041a4); + --csstools-light-dark-toggle--78: var(--csstools-color-scheme--light) + #15141a; + --comment-button-active-fg: var(--csstools-light-dark-toggle--78, white); + --csstools-light-dark-toggle--79: var(--csstools-color-scheme--light) + #61dce9; + --comment-button-hover-bg: var(--csstools-light-dark-toggle--79, #0053cb); + --csstools-light-dark-toggle--80: var(--csstools-color-scheme--light) + #15141a; + --comment-button-hover-fg: var(--csstools-light-dark-toggle--80, white); + --csstools-light-dark-toggle--81: var(--csstools-color-scheme--light) + #00cadb; + --comment-button-selected-bg: var( + --csstools-light-dark-toggle--81, + #0062fa + ); + --csstools-light-dark-toggle--82: var(--csstools-color-scheme--light) + #bfbfc9; + --comment-button-border-color: var( + --csstools-light-dark-toggle--82, + #8f8f9d + ); + --csstools-light-dark-toggle--83: var(--csstools-color-scheme--light) + #3a3944; + --comment-button-focus-border-color: var( + --csstools-light-dark-toggle--83, + #cfcfd8 + ); + --csstools-light-dark-toggle--84: var(--csstools-color-scheme--light) + #15141a; + --comment-button-selected-fg: var(--csstools-light-dark-toggle--84, white); + --csstools-light-dark-toggle--85: var(--csstools-color-scheme--light) + rgb(0 0 0 / 0.2); + --csstools-light-dark-toggle--86: var(--csstools-color-scheme--light) + rgb(0 0 0 / 0.4); + --comment-button-box-shadow: + 0 0.25px 0.75px 0 var(--csstools-light-dark-toggle--85, rgb(0 0 0 / 0.05)), + 0 2px 6px 0 var(--csstools-light-dark-toggle--86, rgb(0 0 0 / 0.1)); + --csstools-light-dark-toggle--87: var(--csstools-color-scheme--light) + #00cadb; + --comment-button-focus-outline-color: var( + --csstools-light-dark-toggle--87, + #0062fa + ); } } -@media (prefers-color-scheme: dark){ - -:is(.annotationLayer,.annotationEditorLayer) .annotationCommentButton{ +@media (prefers-color-scheme: dark) { + :is(.annotationLayer, .annotationEditorLayer) .annotationCommentButton { --csstools-color-scheme--light: light; } } -@media screen and (forced-colors: active){ - -:is(.annotationLayer,.annotationEditorLayer) .annotationCommentButton{ - --comment-button-bg:ButtonFace; - --comment-button-fg:ButtonText; - --comment-button-hover-bg:SelectedItemText; - --comment-button-hover-fg:SelectedItem; - --comment-button-active-bg:SelectedItemText; - --comment-button-active-fg:SelectedItem; - --comment-button-border-color:ButtonBorder; - --comment-button-active-border-color:ButtonBorder; - --comment-button-hover-border-color:SelectedItem; - --comment-button-box-shadow:none; - --comment-button-focus-outline-color:CanvasText; - --comment-button-selected-bg:ButtonBorder; - --comment-button-selected-fg:ButtonFace; +@media screen and (forced-colors: active) { + :is(.annotationLayer, .annotationEditorLayer) .annotationCommentButton { + --comment-button-bg: ButtonFace; + --comment-button-fg: ButtonText; + --comment-button-hover-bg: SelectedItemText; + --comment-button-hover-fg: SelectedItem; + --comment-button-active-bg: SelectedItemText; + --comment-button-active-fg: SelectedItem; + --comment-button-border-color: ButtonBorder; + --comment-button-active-border-color: ButtonBorder; + --comment-button-hover-border-color: SelectedItem; + --comment-button-box-shadow: none; + --comment-button-focus-outline-color: CanvasText; + --comment-button-selected-bg: ButtonBorder; + --comment-button-selected-fg: ButtonFace; } - } +} -:is(.annotationLayer,.annotationEditorLayer) .annotationCommentButton{ +:is(.annotationLayer, .annotationEditorLayer) .annotationCommentButton { + position: absolute; + width: var(--comment-button-dim); + height: var(--comment-button-dim); + background-color: var(--comment-button-bg); + border-radius: 6px 6px 6px 0; + border: 1px solid var(--comment-button-border-color); + box-shadow: var(--comment-button-box-shadow); + cursor: auto; + z-index: 1; + padding: 4px; + margin: 0; + box-sizing: border-box; + pointer-events: auto; +} - position:absolute; - width:var(--comment-button-dim); - height:var(--comment-button-dim); - background-color:var(--comment-button-bg); - border-radius:6px 6px 6px 0; - border:1px solid var(--comment-button-border-color); - box-shadow:var(--comment-button-box-shadow); - cursor:auto; - z-index:1; - padding:4px; - margin:0; - box-sizing:border-box; - pointer-events:auto; - } +[dir='rtl'] + :is(:is(.annotationLayer, .annotationEditorLayer) .annotationCommentButton) { + border-radius: 6px 6px 0; +} -[dir="rtl"] :is(:is(.annotationLayer,.annotationEditorLayer) .annotationCommentButton){ - border-radius:6px 6px 0; - } +:is( + :is(.annotationLayer, .annotationEditorLayer) .annotationCommentButton +)::before { + content: ''; + display: inline-block; + width: 100%; + height: 100%; + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; + -webkit-mask-size: cover; + mask-size: cover; + -webkit-mask-image: var(--comment-edit-button-icon); + mask-image: var(--comment-edit-button-icon); + background-color: var(--comment-button-fg); + margin: 0; + padding: 0; + transform: scaleX(var(--dir-factor)); +} -:is(:is(.annotationLayer,.annotationEditorLayer) .annotationCommentButton)::before{ - content:""; - display:inline-block; - width:100%; - height:100%; - -webkit-mask-repeat:no-repeat; - mask-repeat:no-repeat; - -webkit-mask-size:cover; - mask-size:cover; - -webkit-mask-image:var(--comment-edit-button-icon); - mask-image:var(--comment-edit-button-icon); - background-color:var(--comment-button-fg); - margin:0; - padding:0; - transform:scaleX(var(--dir-factor)); - } +:is( + :is(.annotationLayer, .annotationEditorLayer) .annotationCommentButton +):focus-visible { + outline: 2px solid var(--comment-button-focus-outline-color); + outline-offset: 1px; + border-color: var(--comment-button-focus-border-color); +} -:is(:is(.annotationLayer,.annotationEditorLayer) .annotationCommentButton):focus-visible{ - outline:2px solid var(--comment-button-focus-outline-color); - outline-offset:1px; - border-color:var(--comment-button-focus-border-color); - } +:is( + :is(.annotationLayer, .annotationEditorLayer) .annotationCommentButton +):hover { + background-color: var(--comment-button-hover-bg) !important; + border-color: var(--comment-button-hover-border-color); +} -:is(:is(.annotationLayer,.annotationEditorLayer) .annotationCommentButton):hover{ - background-color:var(--comment-button-hover-bg) !important; - border-color:var(--comment-button-hover-border-color); - } +:is( + :is(.annotationLayer, .annotationEditorLayer) .annotationCommentButton + ):hover::before { + background-color: var(--comment-button-hover-fg); +} -:is(:is(.annotationLayer,.annotationEditorLayer) .annotationCommentButton):hover::before{ - background-color:var(--comment-button-hover-fg); - } +:is( + :is(.annotationLayer, .annotationEditorLayer) .annotationCommentButton +):active { + background-color: var(--comment-button-active-bg) !important; + border-color: var(--comment-button-active-border-color); +} -:is(:is(.annotationLayer,.annotationEditorLayer) .annotationCommentButton):active{ - background-color:var(--comment-button-active-bg) !important; - border-color:var(--comment-button-active-border-color); - } +:is( + :is(.annotationLayer, .annotationEditorLayer) .annotationCommentButton + ):active::before { + background-color: var(--comment-button-active-fg); +} -:is(:is(.annotationLayer,.annotationEditorLayer) .annotationCommentButton):active::before{ - background-color:var(--comment-button-active-fg); - } +.selected:is( + :is(.annotationLayer, .annotationEditorLayer) .annotationCommentButton +) { + background-color: var(--comment-button-selected-bg) !important; + border-color: var(--comment-button-selected-border-color); +} -.selected:is(:is(.annotationLayer,.annotationEditorLayer) .annotationCommentButton){ - background-color:var(--comment-button-selected-bg) !important; - border-color:var(--comment-button-selected-border-color); - } - -.selected:is(:is(.annotationLayer,.annotationEditorLayer) .annotationCommentButton)::before{ - background-color:var(--comment-button-selected-fg); - } +.selected:is( + :is(.annotationLayer, .annotationEditorLayer) .annotationCommentButton + )::before { + background-color: var(--comment-button-selected-fg); +} #editorCommentsSidebar, -.commentPopup{ - --comment-close-button-icon:url(images/comment-closeButton.svg); - --comment-popup-edit-button-icon:url(images/comment-popup-editButton.svg); - --comment-popup-delete-button-icon:url(images/editor-toolbar-delete.svg); +.commentPopup { + --comment-close-button-icon: url(images/comment-closeButton.svg); + --comment-popup-edit-button-icon: url(images/comment-popup-editButton.svg); + --comment-popup-delete-button-icon: url(images/editor-toolbar-delete.svg); - --csstools-light-dark-toggle--88:var(--csstools-color-scheme--light) rgb(251 251 254 / 0.69); + --csstools-light-dark-toggle--88: var(--csstools-color-scheme--light) + rgb(251 251 254 / 0.69); - --comment-date-fg-color:var(--csstools-light-dark-toggle--88, rgb(21 20 26 / 0.69)); - --csstools-light-dark-toggle--89:var(--csstools-color-scheme--light) #1c1b22; - --comment-bg-color:var(--csstools-light-dark-toggle--89, #f9f9fb); - --csstools-light-dark-toggle--90:var(--csstools-color-scheme--light) #2c2b33; - --comment-hover-bg-color:var(--csstools-light-dark-toggle--90, #e0e0e6); - --csstools-light-dark-toggle--91:var(--csstools-color-scheme--light) #3a3944; - --comment-active-bg-color:var(--csstools-light-dark-toggle--91, #d1d1d9); - --comment-hover-brightness:0.89; - --comment-hover-filter:brightness(var(--comment-hover-brightness)); - --comment-active-brightness:0.825; - --comment-active-filter:brightness(var(--comment-active-brightness)); - --csstools-light-dark-toggle--92:var(--csstools-color-scheme--light) #52525e; - --comment-border-color:var(--csstools-light-dark-toggle--92, #f0f0f4); - --csstools-light-dark-toggle--93:var(--csstools-color-scheme--light) #00cadb; - --comment-focus-outline-color:var(--csstools-light-dark-toggle--93, #0062fa); - --csstools-light-dark-toggle--94:var(--csstools-color-scheme--light) #fbfbfe; - --comment-fg-color:var(--csstools-light-dark-toggle--94, #15141a); - --csstools-light-dark-toggle--95:var(--csstools-color-scheme--light) #00317e; - --comment-count-bg-color:var(--csstools-light-dark-toggle--95, #e2f7ff); - --csstools-light-dark-toggle--96:var(--csstools-color-scheme--light) #a6ecf4; - --comment-indicator-active-fg-color:var(--csstools-light-dark-toggle--96, #0041a4); - --comment-indicator-active-filter:brightness( + --comment-date-fg-color: var( + --csstools-light-dark-toggle--88, + rgb(21 20 26 / 0.69) + ); + --csstools-light-dark-toggle--89: var(--csstools-color-scheme--light) #1c1b22; + --comment-bg-color: var(--csstools-light-dark-toggle--89, #f9f9fb); + --csstools-light-dark-toggle--90: var(--csstools-color-scheme--light) #2c2b33; + --comment-hover-bg-color: var(--csstools-light-dark-toggle--90, #e0e0e6); + --csstools-light-dark-toggle--91: var(--csstools-color-scheme--light) #3a3944; + --comment-active-bg-color: var(--csstools-light-dark-toggle--91, #d1d1d9); + --comment-hover-brightness: 0.89; + --comment-hover-filter: brightness(var(--comment-hover-brightness)); + --comment-active-brightness: 0.825; + --comment-active-filter: brightness(var(--comment-active-brightness)); + --csstools-light-dark-toggle--92: var(--csstools-color-scheme--light) #52525e; + --comment-border-color: var(--csstools-light-dark-toggle--92, #f0f0f4); + --csstools-light-dark-toggle--93: var(--csstools-color-scheme--light) #00cadb; + --comment-focus-outline-color: var(--csstools-light-dark-toggle--93, #0062fa); + --csstools-light-dark-toggle--94: var(--csstools-color-scheme--light) #fbfbfe; + --comment-fg-color: var(--csstools-light-dark-toggle--94, #15141a); + --csstools-light-dark-toggle--95: var(--csstools-color-scheme--light) #00317e; + --comment-count-bg-color: var(--csstools-light-dark-toggle--95, #e2f7ff); + --csstools-light-dark-toggle--96: var(--csstools-color-scheme--light) #a6ecf4; + --comment-indicator-active-fg-color: var( + --csstools-light-dark-toggle--96, + #0041a4 + ); + --comment-indicator-active-filter: brightness( calc(1 / var(--comment-active-brightness)) ); - --csstools-light-dark-toggle--97:var(--csstools-color-scheme--light) #fbfbfe; - --comment-indicator-focus-fg-color:var(--csstools-light-dark-toggle--97, #5b5b66); - --csstools-light-dark-toggle--98:var(--csstools-color-scheme--light) #61dce9; - --comment-indicator-hover-fg-color:var(--csstools-light-dark-toggle--98, #0053cb); - --comment-indicator-hover-filter:brightness( + --csstools-light-dark-toggle--97: var(--csstools-color-scheme--light) #fbfbfe; + --comment-indicator-focus-fg-color: var( + --csstools-light-dark-toggle--97, + #5b5b66 + ); + --csstools-light-dark-toggle--98: var(--csstools-color-scheme--light) #61dce9; + --comment-indicator-hover-fg-color: var( + --csstools-light-dark-toggle--98, + #0053cb + ); + --comment-indicator-hover-filter: brightness( calc(1 / var(--comment-hover-brightness)) ); - --csstools-light-dark-toggle--99:var(--csstools-color-scheme--light) #00cadb; - --comment-indicator-selected-fg-color:var(--csstools-light-dark-toggle--99, #0062fa); - - --button-comment-bg:transparent; - --button-comment-color:var(--main-color); - --csstools-light-dark-toggle--100:var(--csstools-color-scheme--light) #5b5b66; - --button-comment-active-bg:var(--csstools-light-dark-toggle--100, #cfcfd8); - --button-comment-active-border:none; - --button-comment-active-color:var(--button-comment-color); - --button-comment-border:none; - --csstools-light-dark-toggle--101:var(--csstools-color-scheme--light) #52525e; - --button-comment-hover-bg:var(--csstools-light-dark-toggle--101, #e0e0e6); - --button-comment-hover-color:var(--button-comment-color); -} - -@supports (color: light-dark(red, red)) and (color: rgb(0 0 0 / 0)){ -#editorCommentsSidebar, -.commentPopup{ - - --comment-date-fg-color:light-dark( - rgb(21 20 26 / 0.69), - rgb(251 251 254 / 0.69) + --csstools-light-dark-toggle--99: var(--csstools-color-scheme--light) #00cadb; + --comment-indicator-selected-fg-color: var( + --csstools-light-dark-toggle--99, + #0062fa ); -} + + --button-comment-bg: transparent; + --button-comment-color: var(--main-color); + --csstools-light-dark-toggle--100: var(--csstools-color-scheme--light) #5b5b66; + --button-comment-active-bg: var(--csstools-light-dark-toggle--100, #cfcfd8); + --button-comment-active-border: none; + --button-comment-active-color: var(--button-comment-color); + --button-comment-border: none; + --csstools-light-dark-toggle--101: var(--csstools-color-scheme--light) #52525e; + --button-comment-hover-bg: var(--csstools-light-dark-toggle--101, #e0e0e6); + --button-comment-hover-color: var(--button-comment-color); } -@supports (color: light-dark(red, red)){ -#editorCommentsSidebar, -.commentPopup{ - --comment-bg-color:light-dark(#f9f9fb, #1c1b22); - --comment-hover-bg-color:light-dark(#e0e0e6, #2c2b33); - --comment-active-bg-color:light-dark(#d1d1d9, #3a3944); - --comment-border-color:light-dark(#f0f0f4, #52525e); - --comment-focus-outline-color:light-dark(#0062fa, #00cadb); - --comment-fg-color:light-dark(#15141a, #fbfbfe); - --comment-count-bg-color:light-dark(#e2f7ff, #00317e); - --comment-indicator-active-fg-color:light-dark(#0041a4, #a6ecf4); - --comment-indicator-focus-fg-color:light-dark(#5b5b66, #fbfbfe); - --comment-indicator-hover-fg-color:light-dark(#0053cb, #61dce9); - --comment-indicator-selected-fg-color:light-dark(#0062fa, #00cadb); - --button-comment-active-bg:light-dark(#cfcfd8, #5b5b66); - --button-comment-hover-bg:light-dark(#e0e0e6, #52525e); -} -} - -@supports not (color: light-dark(tan, tan)){ - -:is(#editorCommentsSidebar,.commentPopup) *{ - - --csstools-light-dark-toggle--88:var(--csstools-color-scheme--light) rgb(251 251 254 / 0.69); - - --comment-date-fg-color:var(--csstools-light-dark-toggle--88, rgb(21 20 26 / 0.69)); - --csstools-light-dark-toggle--89:var(--csstools-color-scheme--light) #1c1b22; - --comment-bg-color:var(--csstools-light-dark-toggle--89, #f9f9fb); - --csstools-light-dark-toggle--90:var(--csstools-color-scheme--light) #2c2b33; - --comment-hover-bg-color:var(--csstools-light-dark-toggle--90, #e0e0e6); - --csstools-light-dark-toggle--91:var(--csstools-color-scheme--light) #3a3944; - --comment-active-bg-color:var(--csstools-light-dark-toggle--91, #d1d1d9); - --csstools-light-dark-toggle--92:var(--csstools-color-scheme--light) #52525e; - --comment-border-color:var(--csstools-light-dark-toggle--92, #f0f0f4); - --csstools-light-dark-toggle--93:var(--csstools-color-scheme--light) #00cadb; - --comment-focus-outline-color:var(--csstools-light-dark-toggle--93, #0062fa); - --csstools-light-dark-toggle--94:var(--csstools-color-scheme--light) #fbfbfe; - --comment-fg-color:var(--csstools-light-dark-toggle--94, #15141a); - --csstools-light-dark-toggle--95:var(--csstools-color-scheme--light) #00317e; - --comment-count-bg-color:var(--csstools-light-dark-toggle--95, #e2f7ff); - --csstools-light-dark-toggle--96:var(--csstools-color-scheme--light) #a6ecf4; - --comment-indicator-active-fg-color:var(--csstools-light-dark-toggle--96, #0041a4); - --csstools-light-dark-toggle--97:var(--csstools-color-scheme--light) #fbfbfe; - --comment-indicator-focus-fg-color:var(--csstools-light-dark-toggle--97, #5b5b66); - --csstools-light-dark-toggle--98:var(--csstools-color-scheme--light) #61dce9; - --comment-indicator-hover-fg-color:var(--csstools-light-dark-toggle--98, #0053cb); - --csstools-light-dark-toggle--99:var(--csstools-color-scheme--light) #00cadb; - --comment-indicator-selected-fg-color:var(--csstools-light-dark-toggle--99, #0062fa); - --csstools-light-dark-toggle--100:var(--csstools-color-scheme--light) #5b5b66; - --button-comment-active-bg:var(--csstools-light-dark-toggle--100, #cfcfd8); - --csstools-light-dark-toggle--101:var(--csstools-color-scheme--light) #52525e; - --button-comment-hover-bg:var(--csstools-light-dark-toggle--101, #e0e0e6); +@supports (color: light-dark(red, red)) and (color: rgb(0 0 0 / 0)) { + #editorCommentsSidebar, + .commentPopup { + --comment-date-fg-color: light-dark( + rgb(21 20 26 / 0.69), + rgb(251 251 254 / 0.69) + ); } } -@media screen and (forced-colors: active){ - -#editorCommentsSidebar, -.commentPopup{ - --comment-date-fg-color:CanvasText; - --comment-bg-color:Canvas; - --comment-hover-bg-color:Canvas; - --comment-hover-filter:none; - --comment-active-bg-color:Canvas; - --comment-active-filter:none; - --comment-border-color:CanvasText; - --comment-fg-color:CanvasText; - --comment-count-bg-color:Canvas; - --comment-indicator-active-fg-color:SelectedItem; - --comment-indicator-focus-fg-color:CanvasText; - --comment-indicator-hover-fg-color:CanvasText; - --comment-indicator-selected-fg-color:SelectedItem; - --button-comment-bg:ButtonFace; - --button-comment-color:ButtonText; - --button-comment-active-bg:ButtonText; - --button-comment-active-color:HighlightText; - --button-comment-border:1px solid ButtonText; - --button-comment-hover-bg:Highlight; - --button-comment-hover-color:HighlightText; -} - } - -#editorCommentsSidebar{ - display:flex; - height:auto; - padding-bottom:16px; - flex-direction:column; - align-items:flex-start; -} - -#editorCommentsSidebar #editorCommentsSidebarHeader{ - width:100%; - box-sizing:border-box; - padding:16px; - display:flex; - align-items:center; - justify-content:space-between; - } - -:is(#editorCommentsSidebar #editorCommentsSidebarHeader) .commentCount{ - display:flex; - align-items:baseline; - gap:6px; - -webkit-user-select:none; - -moz-user-select:none; - user-select:none; - } - -:is(:is(#editorCommentsSidebar #editorCommentsSidebarHeader) .commentCount) #editorCommentsSidebarTitle{ - font:menu; - font-style:normal; - font-weight:590; - line-height:normal; - font-size:17px; - color:var(--comment-fg-color); - } - -:is(:is(#editorCommentsSidebar #editorCommentsSidebarHeader) .commentCount) #editorCommentsSidebarCount{ - padding:0 4px; - border-radius:4px; - background-color:var(--comment-count-bg-color); - - color:var(--comment-fg-color); - text-align:center; - - font:menu; - font-size:13px; - font-style:normal; - font-weight:400; - line-height:normal; - } - -:is(#editorCommentsSidebar #editorCommentsSidebarHeader) #editorCommentsSidebarCloseButton{ - width:32px; - height:32px; - padding:8px; - border-radius:4px; - border:none; - background:none; - cursor:pointer; - } - -:is(:is(#editorCommentsSidebar #editorCommentsSidebarHeader) #editorCommentsSidebarCloseButton)::before{ - content:""; - display:inline-block; - width:100%; - height:100%; - -webkit-mask-repeat:no-repeat; - mask-repeat:no-repeat; - -webkit-mask-position:center; - mask-position:center; - -webkit-mask-image:var(--comment-close-button-icon); - mask-image:var(--comment-close-button-icon); - background-color:var(--comment-fg-color); - } - -:is(:is(#editorCommentsSidebar #editorCommentsSidebarHeader) #editorCommentsSidebarCloseButton):hover{ - background-color:var(--comment-hover-bg-color); - } - -:is(:is(#editorCommentsSidebar #editorCommentsSidebarHeader) #editorCommentsSidebarCloseButton):active{ - background-color:var(--comment-active-bg-color); - } - -:is(:is(#editorCommentsSidebar #editorCommentsSidebarHeader) #editorCommentsSidebarCloseButton):focus-visible{ - outline:var(--focus-ring-outline); - } - -:is(:is(#editorCommentsSidebar #editorCommentsSidebarHeader) #editorCommentsSidebarCloseButton) > span{ - display:inline-block; - width:0; - height:0; - overflow:hidden; - } - -#editorCommentsSidebar #editorCommentsSidebarListContainer{ - overflow:auto; - width:100%; - } - -:is(#editorCommentsSidebar #editorCommentsSidebarListContainer) #editorCommentsSidebarList{ - display:flex; - width:auto; - padding:4px 16px; - gap:10px; - align-items:flex-start; - flex-direction:column; - list-style-type:none; - } - -:is(:is(#editorCommentsSidebar #editorCommentsSidebarListContainer) #editorCommentsSidebarList) .sidebarComment{ - display:flex; - width:auto; - padding:8px 16px 16px; - flex-direction:column; - align-items:flex-start; - align-self:stretch; - gap:4px; - - border-radius:8px; - border:0.5px solid var(--comment-border-color); - background-color:var(--comment-bg-color); - } - -@media screen and (forced-colors: active){ - -:is(:is(:is(#editorCommentsSidebar #editorCommentsSidebarListContainer) #editorCommentsSidebarList) .sidebarComment):not(.noComments):hover{ - background-color:var(--comment-hover-bg-color); - } - } - -:is(:is(:is(#editorCommentsSidebar #editorCommentsSidebarListContainer) #editorCommentsSidebarList) .sidebarComment):not(.noComments):hover{ - filter:var(--comment-hover-filter); - } - -:is(:is(:is(#editorCommentsSidebar #editorCommentsSidebarListContainer) #editorCommentsSidebarList) .sidebarComment):not(.noComments):hover time::after{ - display:inline-block; - background-color:var(--comment-indicator-hover-fg-color); - filter:var(--comment-indicator-hover-filter); - } - -@media screen and (forced-colors: active){ - -:is(:is(:is(#editorCommentsSidebar #editorCommentsSidebarListContainer) #editorCommentsSidebarList) .sidebarComment):not(.noComments):active{ - background-color:var(--comment-active-bg-color); - } - } - -:is(:is(:is(#editorCommentsSidebar #editorCommentsSidebarListContainer) #editorCommentsSidebarList) .sidebarComment):not(.noComments):active{ - filter:var(--comment-active-filter); - } - -:is(:is(:is(#editorCommentsSidebar #editorCommentsSidebarListContainer) #editorCommentsSidebarList) .sidebarComment):not(.noComments):active time::after{ - display:inline-block; - background-color:var(--comment-indicator-active-fg-color); - filter:var(--comment-indicator-active-filter); - } - -:is(:is(:is(#editorCommentsSidebar #editorCommentsSidebarListContainer) #editorCommentsSidebarList) .sidebarComment):not(.noComments):is(:focus,:focus-visible) time::after{ - display:inline-block; - background-color:var(--comment-indicator-focus-fg-color); - } - -:is(:is(:is(#editorCommentsSidebar #editorCommentsSidebarListContainer) #editorCommentsSidebarList) .sidebarComment):not(.noComments):focus-visible{ - outline:2px solid var(--comment-focus-outline-color); - outline-offset:2px; - } - -.selected:is(:is(:is(#editorCommentsSidebar #editorCommentsSidebarListContainer) #editorCommentsSidebarList) .sidebarComment):not(.noComments) .sidebarCommentText{ - max-height:-moz-fit-content; - max-height:fit-content; - -webkit-line-clamp:unset; - } - -.selected:is(:is(:is(#editorCommentsSidebar #editorCommentsSidebarListContainer) #editorCommentsSidebarList) .sidebarComment):not(.noComments) time::after{ - display:inline-block; - background-color:var(--comment-indicator-selected-fg-color); - } - -:is(:is(:is(#editorCommentsSidebar #editorCommentsSidebarListContainer) #editorCommentsSidebarList) .sidebarComment) .sidebarCommentText{ - font:menu; - font-style:normal; - font-weight:400; - line-height:normal; - font-size:15px; - width:100%; - height:-moz-fit-content; - height:fit-content; - max-height:80px; - display:-webkit-box; - -webkit-box-orient:vertical; - -webkit-line-clamp:2; - overflow:hidden; - overflow-wrap:break-word; - } - -:is(:is(:is(:is(#editorCommentsSidebar #editorCommentsSidebarListContainer) #editorCommentsSidebarList) .sidebarComment) .sidebarCommentText) .richText{ - --total-scale-factor:1.5; - } - -.noComments:is(:is(:is(#editorCommentsSidebar #editorCommentsSidebarListContainer) #editorCommentsSidebarList) .sidebarComment) .sidebarCommentText{ - max-height:-moz-fit-content; - max-height:fit-content; - -webkit-line-clamp:unset; - -webkit-user-select:none; - -moz-user-select:none; - user-select:none; - } - -.noComments:is(:is(:is(#editorCommentsSidebar #editorCommentsSidebarListContainer) #editorCommentsSidebarList) .sidebarComment) a{ - font:menu; - font-style:normal; - font-weight:400; - line-height:normal; - font-size:15px; - width:100%; - height:auto; - overflow-wrap:break-word; - margin-block-start:15px; - } - -:is(.noComments:is(:is(:is(#editorCommentsSidebar #editorCommentsSidebarListContainer) #editorCommentsSidebarList) .sidebarComment) a):focus-visible{ - outline:var(--focus-ring-outline); - } - -:is(:is(:is(#editorCommentsSidebar #editorCommentsSidebarListContainer) #editorCommentsSidebarList) .sidebarComment) time{ - width:100%; - display:inline-flex; - align-items:center; - justify-content:space-between; - - font:menu; - font-style:normal; - font-weight:400; - line-height:normal; - font-size:13px; - } - -:is(:is(:is(:is(#editorCommentsSidebar #editorCommentsSidebarListContainer) #editorCommentsSidebarList) .sidebarComment) time)::after{ - content:""; - display:none; - width:16px; - height:16px; - -webkit-mask-repeat:no-repeat; - mask-repeat:no-repeat; - -webkit-mask-position:center; - mask-position:center; - -webkit-mask-image:var(--comment-edit-button-icon); - mask-image:var(--comment-edit-button-icon); - transform:scaleX(var(--dir-factor)); - } - -.commentPopup{ - --csstools-color-scheme--light:initial; - color-scheme:light dark; - - --csstools-light-dark-toggle--102:var(--csstools-color-scheme--light) #3a3944; - - --divider-color:var(--csstools-light-dark-toggle--102, #cfcfd8); - --csstools-light-dark-toggle--103:var(--csstools-color-scheme--light) rgb(0 0 0 / 0.2); - --csstools-light-dark-toggle--104:var(--csstools-color-scheme--light) rgb(0 0 0 / 0.4); - --comment-shadow:0 0.5px 2px 0 var(--csstools-light-dark-toggle--103, rgb(0 0 0 / 0.05)), 0 4px 16px 0 var(--csstools-light-dark-toggle--104, rgb(0 0 0 / 0.1)); -} - -@supports (color: light-dark(red, red)){ -.commentPopup{ - - --divider-color:light-dark(#cfcfd8, #3a3944); -} -} - -@supports (color: light-dark(red, red)) and (color: rgb(0 0 0 / 0)){ -.commentPopup{ - --comment-shadow:0 0.5px 2px 0 light-dark(rgb(0 0 0 / 0.05), rgb(0 0 0 / 0.2)), 0 4px 16px 0 light-dark(rgb(0 0 0 / 0.1), rgb(0 0 0 / 0.4)); -} -} - -@supports not (color: light-dark(tan, tan)){ - -.commentPopup *{ - - --csstools-light-dark-toggle--102:var(--csstools-color-scheme--light) #3a3944; - - --divider-color:var(--csstools-light-dark-toggle--102, #cfcfd8); - --csstools-light-dark-toggle--103:var(--csstools-color-scheme--light) rgb(0 0 0 / 0.2); - --csstools-light-dark-toggle--104:var(--csstools-color-scheme--light) rgb(0 0 0 / 0.4); - --comment-shadow:0 0.5px 2px 0 var(--csstools-light-dark-toggle--103, rgb(0 0 0 / 0.05)), 0 4px 16px 0 var(--csstools-light-dark-toggle--104, rgb(0 0 0 / 0.1)); +@supports (color: light-dark(red, red)) { + #editorCommentsSidebar, + .commentPopup { + --comment-bg-color: light-dark(#f9f9fb, #1c1b22); + --comment-hover-bg-color: light-dark(#e0e0e6, #2c2b33); + --comment-active-bg-color: light-dark(#d1d1d9, #3a3944); + --comment-border-color: light-dark(#f0f0f4, #52525e); + --comment-focus-outline-color: light-dark(#0062fa, #00cadb); + --comment-fg-color: light-dark(#15141a, #fbfbfe); + --comment-count-bg-color: light-dark(#e2f7ff, #00317e); + --comment-indicator-active-fg-color: light-dark(#0041a4, #a6ecf4); + --comment-indicator-focus-fg-color: light-dark(#5b5b66, #fbfbfe); + --comment-indicator-hover-fg-color: light-dark(#0053cb, #61dce9); + --comment-indicator-selected-fg-color: light-dark(#0062fa, #00cadb); + --button-comment-active-bg: light-dark(#cfcfd8, #5b5b66); + --button-comment-hover-bg: light-dark(#e0e0e6, #52525e); } } -@media (prefers-color-scheme: dark){ +@supports not (color: light-dark(tan, tan)) { + :is(#editorCommentsSidebar, .commentPopup) * { + --csstools-light-dark-toggle--88: var(--csstools-color-scheme--light) + rgb(251 251 254 / 0.69); -.commentPopup{ - --csstools-color-scheme--light: light; -} + --comment-date-fg-color: var( + --csstools-light-dark-toggle--88, + rgb(21 20 26 / 0.69) + ); + --csstools-light-dark-toggle--89: var(--csstools-color-scheme--light) + #1c1b22; + --comment-bg-color: var(--csstools-light-dark-toggle--89, #f9f9fb); + --csstools-light-dark-toggle--90: var(--csstools-color-scheme--light) + #2c2b33; + --comment-hover-bg-color: var(--csstools-light-dark-toggle--90, #e0e0e6); + --csstools-light-dark-toggle--91: var(--csstools-color-scheme--light) + #3a3944; + --comment-active-bg-color: var(--csstools-light-dark-toggle--91, #d1d1d9); + --csstools-light-dark-toggle--92: var(--csstools-color-scheme--light) + #52525e; + --comment-border-color: var(--csstools-light-dark-toggle--92, #f0f0f4); + --csstools-light-dark-toggle--93: var(--csstools-color-scheme--light) + #00cadb; + --comment-focus-outline-color: var( + --csstools-light-dark-toggle--93, + #0062fa + ); + --csstools-light-dark-toggle--94: var(--csstools-color-scheme--light) + #fbfbfe; + --comment-fg-color: var(--csstools-light-dark-toggle--94, #15141a); + --csstools-light-dark-toggle--95: var(--csstools-color-scheme--light) + #00317e; + --comment-count-bg-color: var(--csstools-light-dark-toggle--95, #e2f7ff); + --csstools-light-dark-toggle--96: var(--csstools-color-scheme--light) + #a6ecf4; + --comment-indicator-active-fg-color: var( + --csstools-light-dark-toggle--96, + #0041a4 + ); + --csstools-light-dark-toggle--97: var(--csstools-color-scheme--light) + #fbfbfe; + --comment-indicator-focus-fg-color: var( + --csstools-light-dark-toggle--97, + #5b5b66 + ); + --csstools-light-dark-toggle--98: var(--csstools-color-scheme--light) + #61dce9; + --comment-indicator-hover-fg-color: var( + --csstools-light-dark-toggle--98, + #0053cb + ); + --csstools-light-dark-toggle--99: var(--csstools-color-scheme--light) + #00cadb; + --comment-indicator-selected-fg-color: var( + --csstools-light-dark-toggle--99, + #0062fa + ); + --csstools-light-dark-toggle--100: var(--csstools-color-scheme--light) + #5b5b66; + --button-comment-active-bg: var(--csstools-light-dark-toggle--100, #cfcfd8); + --csstools-light-dark-toggle--101: var(--csstools-color-scheme--light) + #52525e; + --button-comment-hover-bg: var(--csstools-light-dark-toggle--101, #e0e0e6); + } } -@media screen and (forced-colors: active){ - -.commentPopup{ - --divider-color:CanvasText; - --comment-shadow:none; -} +@media screen and (forced-colors: active) { + #editorCommentsSidebar, + .commentPopup { + --comment-date-fg-color: CanvasText; + --comment-bg-color: Canvas; + --comment-hover-bg-color: Canvas; + --comment-hover-filter: none; + --comment-active-bg-color: Canvas; + --comment-active-filter: none; + --comment-border-color: CanvasText; + --comment-fg-color: CanvasText; + --comment-count-bg-color: Canvas; + --comment-indicator-active-fg-color: SelectedItem; + --comment-indicator-focus-fg-color: CanvasText; + --comment-indicator-hover-fg-color: CanvasText; + --comment-indicator-selected-fg-color: SelectedItem; + --button-comment-bg: ButtonFace; + --button-comment-color: ButtonText; + --button-comment-active-bg: ButtonText; + --button-comment-active-color: HighlightText; + --button-comment-border: 1px solid ButtonText; + --button-comment-hover-bg: Highlight; + --button-comment-hover-color: HighlightText; } - -.commentPopup{ - - display:flex; - flex-direction:column; - align-items:flex-start; - gap:12px; - z-index:100001; - pointer-events:auto; - margin-top:2px; - - border:0.5px solid var(--comment-border-color); - background:var(--comment-bg-color); - box-shadow:var(--comment-shadow); } -.commentPopup:focus-visible{ - outline:none; +#editorCommentsSidebar { + display: flex; + height: auto; + padding-bottom: 16px; + flex-direction: column; + align-items: flex-start; +} + +#editorCommentsSidebar #editorCommentsSidebarHeader { + width: 100%; + box-sizing: border-box; + padding: 16px; + display: flex; + align-items: center; + justify-content: space-between; +} + +:is(#editorCommentsSidebar #editorCommentsSidebarHeader) .commentCount { + display: flex; + align-items: baseline; + gap: 6px; + -webkit-user-select: none; + -moz-user-select: none; + user-select: none; +} + +:is(:is(#editorCommentsSidebar #editorCommentsSidebarHeader) .commentCount) + #editorCommentsSidebarTitle { + font: menu; + font-style: normal; + font-weight: 590; + line-height: normal; + font-size: 17px; + color: var(--comment-fg-color); +} + +:is(:is(#editorCommentsSidebar #editorCommentsSidebarHeader) .commentCount) + #editorCommentsSidebarCount { + padding: 0 4px; + border-radius: 4px; + background-color: var(--comment-count-bg-color); + + color: var(--comment-fg-color); + text-align: center; + + font: menu; + font-size: 13px; + font-style: normal; + font-weight: 400; + line-height: normal; +} + +:is(#editorCommentsSidebar #editorCommentsSidebarHeader) + #editorCommentsSidebarCloseButton { + width: 32px; + height: 32px; + padding: 8px; + border-radius: 4px; + border: none; + background: none; + cursor: pointer; +} + +:is( + :is(#editorCommentsSidebar #editorCommentsSidebarHeader) + #editorCommentsSidebarCloseButton +)::before { + content: ''; + display: inline-block; + width: 100%; + height: 100%; + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; + -webkit-mask-position: center; + mask-position: center; + -webkit-mask-image: var(--comment-close-button-icon); + mask-image: var(--comment-close-button-icon); + background-color: var(--comment-fg-color); +} + +:is( + :is(#editorCommentsSidebar #editorCommentsSidebarHeader) + #editorCommentsSidebarCloseButton +):hover { + background-color: var(--comment-hover-bg-color); +} + +:is( + :is(#editorCommentsSidebar #editorCommentsSidebarHeader) + #editorCommentsSidebarCloseButton +):active { + background-color: var(--comment-active-bg-color); +} + +:is( + :is(#editorCommentsSidebar #editorCommentsSidebarHeader) + #editorCommentsSidebarCloseButton +):focus-visible { + outline: var(--focus-ring-outline); +} + +:is( + :is(#editorCommentsSidebar #editorCommentsSidebarHeader) + #editorCommentsSidebarCloseButton + ) + > span { + display: inline-block; + width: 0; + height: 0; + overflow: hidden; +} + +#editorCommentsSidebar #editorCommentsSidebarListContainer { + overflow: auto; + width: 100%; +} + +:is(#editorCommentsSidebar #editorCommentsSidebarListContainer) + #editorCommentsSidebarList { + display: flex; + width: auto; + padding: 4px 16px; + gap: 10px; + align-items: flex-start; + flex-direction: column; + list-style-type: none; +} + +:is( + :is(#editorCommentsSidebar #editorCommentsSidebarListContainer) + #editorCommentsSidebarList + ) + .sidebarComment { + display: flex; + width: auto; + padding: 8px 16px 16px; + flex-direction: column; + align-items: flex-start; + align-self: stretch; + gap: 4px; + + border-radius: 8px; + border: 0.5px solid var(--comment-border-color); + background-color: var(--comment-bg-color); +} + +@media screen and (forced-colors: active) { + :is( + :is( + :is(#editorCommentsSidebar #editorCommentsSidebarListContainer) + #editorCommentsSidebarList + ) + .sidebarComment + ):not(.noComments):hover { + background-color: var(--comment-hover-bg-color); } +} -.commentPopup.dragging{ - cursor:move !important; +:is( + :is( + :is(#editorCommentsSidebar #editorCommentsSidebarListContainer) + #editorCommentsSidebarList + ) + .sidebarComment + ):not(.noComments):hover { + filter: var(--comment-hover-filter); +} + +:is( + :is( + :is(#editorCommentsSidebar #editorCommentsSidebarListContainer) + #editorCommentsSidebarList + ) + .sidebarComment + ):not(.noComments):hover + time::after { + display: inline-block; + background-color: var(--comment-indicator-hover-fg-color); + filter: var(--comment-indicator-hover-filter); +} + +@media screen and (forced-colors: active) { + :is( + :is( + :is(#editorCommentsSidebar #editorCommentsSidebarListContainer) + #editorCommentsSidebarList + ) + .sidebarComment + ):not(.noComments):active { + background-color: var(--comment-active-bg-color); } +} -.commentPopup.dragging *{ - cursor:move !important; - } +:is( + :is( + :is(#editorCommentsSidebar #editorCommentsSidebarListContainer) + #editorCommentsSidebarList + ) + .sidebarComment + ):not(.noComments):active { + filter: var(--comment-active-filter); +} -.commentPopup.dragging button{ - pointer-events:none !important; - } +:is( + :is( + :is(#editorCommentsSidebar #editorCommentsSidebarListContainer) + #editorCommentsSidebarList + ) + .sidebarComment + ):not(.noComments):active + time::after { + display: inline-block; + background-color: var(--comment-indicator-active-fg-color); + filter: var(--comment-indicator-active-filter); +} -.commentPopup:not(.selected) .commentPopupButtons{ - visibility:hidden !important; +:is( + :is( + :is(#editorCommentsSidebar #editorCommentsSidebarListContainer) + #editorCommentsSidebarList + ) + .sidebarComment + ):not(.noComments):is(:focus, :focus-visible) + time::after { + display: inline-block; + background-color: var(--comment-indicator-focus-fg-color); +} + +:is( + :is( + :is(#editorCommentsSidebar #editorCommentsSidebarListContainer) + #editorCommentsSidebarList + ) + .sidebarComment + ):not(.noComments):focus-visible { + outline: 2px solid var(--comment-focus-outline-color); + outline-offset: 2px; +} + +.selected:is( + :is( + :is(#editorCommentsSidebar #editorCommentsSidebarListContainer) + #editorCommentsSidebarList + ) + .sidebarComment + ):not(.noComments) + .sidebarCommentText { + max-height: -moz-fit-content; + max-height: fit-content; + -webkit-line-clamp: unset; +} + +.selected:is( + :is( + :is(#editorCommentsSidebar #editorCommentsSidebarListContainer) + #editorCommentsSidebarList + ) + .sidebarComment + ):not(.noComments) + time::after { + display: inline-block; + background-color: var(--comment-indicator-selected-fg-color); +} + +:is( + :is( + :is(#editorCommentsSidebar #editorCommentsSidebarListContainer) + #editorCommentsSidebarList + ) + .sidebarComment + ) + .sidebarCommentText { + font: menu; + font-style: normal; + font-weight: 400; + line-height: normal; + font-size: 15px; + width: 100%; + height: -moz-fit-content; + height: fit-content; + max-height: 80px; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + overflow: hidden; + overflow-wrap: break-word; +} + +:is( + :is( + :is( + :is(#editorCommentsSidebar #editorCommentsSidebarListContainer) + #editorCommentsSidebarList + ) + .sidebarComment + ) + .sidebarCommentText + ) + .richText { + --total-scale-factor: 1.5; +} + +.noComments:is( + :is( + :is(#editorCommentsSidebar #editorCommentsSidebarListContainer) + #editorCommentsSidebarList + ) + .sidebarComment + ) + .sidebarCommentText { + max-height: -moz-fit-content; + max-height: fit-content; + -webkit-line-clamp: unset; + -webkit-user-select: none; + -moz-user-select: none; + user-select: none; +} + +.noComments:is( + :is( + :is(#editorCommentsSidebar #editorCommentsSidebarListContainer) + #editorCommentsSidebarList + ) + .sidebarComment + ) + a { + font: menu; + font-style: normal; + font-weight: 400; + line-height: normal; + font-size: 15px; + width: 100%; + height: auto; + overflow-wrap: break-word; + margin-block-start: 15px; +} + +:is( + .noComments:is( + :is( + :is(#editorCommentsSidebar #editorCommentsSidebarListContainer) + #editorCommentsSidebarList + ) + .sidebarComment + ) + a +):focus-visible { + outline: var(--focus-ring-outline); +} + +:is( + :is( + :is(#editorCommentsSidebar #editorCommentsSidebarListContainer) + #editorCommentsSidebarList + ) + .sidebarComment + ) + time { + width: 100%; + display: inline-flex; + align-items: center; + justify-content: space-between; + + font: menu; + font-style: normal; + font-weight: 400; + line-height: normal; + font-size: 13px; +} + +:is( + :is( + :is( + :is(#editorCommentsSidebar #editorCommentsSidebarListContainer) + #editorCommentsSidebarList + ) + .sidebarComment + ) + time +)::after { + content: ''; + display: none; + width: 16px; + height: 16px; + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; + -webkit-mask-position: center; + mask-position: center; + -webkit-mask-image: var(--comment-edit-button-icon); + mask-image: var(--comment-edit-button-icon); + transform: scaleX(var(--dir-factor)); +} + +.commentPopup { + --csstools-color-scheme--light: initial; + color-scheme: light dark; + + --csstools-light-dark-toggle--102: var(--csstools-color-scheme--light) #3a3944; + + --divider-color: var(--csstools-light-dark-toggle--102, #cfcfd8); + --csstools-light-dark-toggle--103: var(--csstools-color-scheme--light) + rgb(0 0 0 / 0.2); + --csstools-light-dark-toggle--104: var(--csstools-color-scheme--light) + rgb(0 0 0 / 0.4); + --comment-shadow: + 0 0.5px 2px 0 var(--csstools-light-dark-toggle--103, rgb(0 0 0 / 0.05)), + 0 4px 16px 0 var(--csstools-light-dark-toggle--104, rgb(0 0 0 / 0.1)); +} + +@supports (color: light-dark(red, red)) { + .commentPopup { + --divider-color: light-dark(#cfcfd8, #3a3944); } +} -.commentPopup hr{ - width:100%; - height:1px; - border:none; - border-top:1px solid var(--divider-color); - margin:0; - padding:0; +@supports (color: light-dark(red, red)) and (color: rgb(0 0 0 / 0)) { + .commentPopup { + --comment-shadow: + 0 0.5px 2px 0 light-dark(rgb(0 0 0 / 0.05), rgb(0 0 0 / 0.2)), + 0 4px 16px 0 light-dark(rgb(0 0 0 / 0.1), rgb(0 0 0 / 0.4)); } +} -.commentPopup .commentPopupTop{ - display:flex; - width:100%; - height:auto; - padding-bottom:4px; - justify-content:space-between; - align-items:center; - align-self:stretch; - cursor:move; - -webkit-user-select:none; - -moz-user-select:none; - user-select:none; +@supports not (color: light-dark(tan, tan)) { + .commentPopup * { + --csstools-light-dark-toggle--102: var(--csstools-color-scheme--light) + #3a3944; + + --divider-color: var(--csstools-light-dark-toggle--102, #cfcfd8); + --csstools-light-dark-toggle--103: var(--csstools-color-scheme--light) + rgb(0 0 0 / 0.2); + --csstools-light-dark-toggle--104: var(--csstools-color-scheme--light) + rgb(0 0 0 / 0.4); + --comment-shadow: + 0 0.5px 2px 0 var(--csstools-light-dark-toggle--103, rgb(0 0 0 / 0.05)), + 0 4px 16px 0 var(--csstools-light-dark-toggle--104, rgb(0 0 0 / 0.1)); } +} -:is(.commentPopup .commentPopupTop) .commentPopupTime{ - font:menu; - font-style:normal; - font-weight:400; - line-height:normal; - font-size:13px; - color:var(--comment-date-fg-color); - } - -:is(.commentPopup .commentPopupTop) .commentPopupButtons{ - display:flex; - align-items:center; - gap:2px; - cursor:default; - } - -:is(:is(.commentPopup .commentPopupTop) .commentPopupButtons) > button{ - width:32px; - height:32px; - padding:8px; - border:var(--button-comment-border); - border-radius:4px; - background-color:var(--button-comment-bg); - color:var(--button-comment-color); - } - -:is(:is(:is(.commentPopup .commentPopupTop) .commentPopupButtons) > button):hover{ - background-color:var(--button-comment-hover-bg); - } - -:is(:is(:is(.commentPopup .commentPopupTop) .commentPopupButtons) > button):hover::before{ - background-color:var(--button-comment-hover-color); - } - -:is(:is(:is(.commentPopup .commentPopupTop) .commentPopupButtons) > button):active{ - border:var(--button-comment-active-border); - background-color:var(--button-comment-active-bg); - color:var(--button-comment-active-color); - } - -:is(:is(:is(.commentPopup .commentPopupTop) .commentPopupButtons) > button):active::before{ - background-color:var(--button-comment-active-color); - } - -:is(:is(:is(.commentPopup .commentPopupTop) .commentPopupButtons) > button):focus-visible{ - background-color:var(--button-comment-hover-bg); - outline:2px solid var(--comment-focus-outline-color); - outline-offset:0; - } - -:is(:is(:is(.commentPopup .commentPopupTop) .commentPopupButtons) > button)::before{ - content:""; - display:inline-block; - width:100%; - height:100%; - -webkit-mask-repeat:no-repeat; - mask-repeat:no-repeat; - -webkit-mask-position:center; - mask-position:center; - } - -.commentPopupEdit:is(:is(:is(.commentPopup .commentPopupTop) .commentPopupButtons) > button)::before{ - -webkit-mask-image:var(--comment-popup-edit-button-icon); - mask-image:var(--comment-popup-edit-button-icon); - } - -.commentPopupDelete:is(:is(:is(.commentPopup .commentPopupTop) .commentPopupButtons) > button)::before{ - -webkit-mask-image:var(--comment-popup-delete-button-icon); - mask-image:var(--comment-popup-delete-button-icon); - } - -.commentPopup .commentPopupText{ - width:100%; - height:auto; - - font:menu; - font-style:normal; - font-weight:400; - line-height:normal; - font-size:15px; - color:var(--comment-fg-color); +@media (prefers-color-scheme: dark) { + .commentPopup { + --csstools-color-scheme--light: light; } +} + +@media screen and (forced-colors: active) { + .commentPopup { + --divider-color: CanvasText; + --comment-shadow: none; + } +} + +.commentPopup { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 12px; + z-index: 100001; + pointer-events: auto; + margin-top: 2px; + + border: 0.5px solid var(--comment-border-color); + background: var(--comment-bg-color); + box-shadow: var(--comment-shadow); +} + +.commentPopup:focus-visible { + outline: none; +} + +.commentPopup.dragging { + cursor: move !important; +} + +.commentPopup.dragging * { + cursor: move !important; +} + +.commentPopup.dragging button { + pointer-events: none !important; +} + +.commentPopup:not(.selected) .commentPopupButtons { + visibility: hidden !important; +} + +.commentPopup hr { + width: 100%; + height: 1px; + border: none; + border-top: 1px solid var(--divider-color); + margin: 0; + padding: 0; +} + +.commentPopup .commentPopupTop { + display: flex; + width: 100%; + height: auto; + padding-bottom: 4px; + justify-content: space-between; + align-items: center; + align-self: stretch; + cursor: move; + -webkit-user-select: none; + -moz-user-select: none; + user-select: none; +} + +:is(.commentPopup .commentPopupTop) .commentPopupTime { + font: menu; + font-style: normal; + font-weight: 400; + line-height: normal; + font-size: 13px; + color: var(--comment-date-fg-color); +} + +:is(.commentPopup .commentPopupTop) .commentPopupButtons { + display: flex; + align-items: center; + gap: 2px; + cursor: default; +} + +:is(:is(.commentPopup .commentPopupTop) .commentPopupButtons) > button { + width: 32px; + height: 32px; + padding: 8px; + border: var(--button-comment-border); + border-radius: 4px; + background-color: var(--button-comment-bg); + color: var(--button-comment-color); +} + +:is( + :is(:is(.commentPopup .commentPopupTop) .commentPopupButtons) > button +):hover { + background-color: var(--button-comment-hover-bg); +} + +:is( + :is(:is(.commentPopup .commentPopupTop) .commentPopupButtons) > button + ):hover::before { + background-color: var(--button-comment-hover-color); +} + +:is( + :is(:is(.commentPopup .commentPopupTop) .commentPopupButtons) > button +):active { + border: var(--button-comment-active-border); + background-color: var(--button-comment-active-bg); + color: var(--button-comment-active-color); +} + +:is( + :is(:is(.commentPopup .commentPopupTop) .commentPopupButtons) > button + ):active::before { + background-color: var(--button-comment-active-color); +} + +:is( + :is(:is(.commentPopup .commentPopupTop) .commentPopupButtons) > button +):focus-visible { + background-color: var(--button-comment-hover-bg); + outline: 2px solid var(--comment-focus-outline-color); + outline-offset: 0; +} + +:is( + :is(:is(.commentPopup .commentPopupTop) .commentPopupButtons) > button +)::before { + content: ''; + display: inline-block; + width: 100%; + height: 100%; + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; + -webkit-mask-position: center; + mask-position: center; +} + +.commentPopupEdit:is( + :is(:is(.commentPopup .commentPopupTop) .commentPopupButtons) > button + )::before { + -webkit-mask-image: var(--comment-popup-edit-button-icon); + mask-image: var(--comment-popup-edit-button-icon); +} + +.commentPopupDelete:is( + :is(:is(.commentPopup .commentPopupTop) .commentPopupButtons) > button + )::before { + -webkit-mask-image: var(--comment-popup-delete-button-icon); + mask-image: var(--comment-popup-delete-button-icon); +} + +.commentPopup .commentPopupText { + width: 100%; + height: auto; + + font: menu; + font-style: normal; + font-weight: 400; + line-height: normal; + font-size: 15px; + color: var(--comment-fg-color); +} .commentPopupText, -.sidebarCommentText .richText{ - margin-block:0; +.sidebarCommentText .richText { + margin-block: 0; } -:is(.commentPopupText,.sidebarCommentText .richText) p:first-of-type{ - margin-block:0; - } +:is(.commentPopupText, .sidebarCommentText .richText) p:first-of-type { + margin-block: 0; +} -:is(.commentPopupText,.sidebarCommentText .richText) > *{ - white-space:pre-wrap; - font-size:max(15px, calc(10px * var(--total-scale-factor))); - overflow-wrap:break-word; - } +:is(.commentPopupText, .sidebarCommentText .richText) > * { + white-space: pre-wrap; + font-size: max(15px, calc(10px * var(--total-scale-factor))); + overflow-wrap: break-word; +} -:is(.commentPopupText,.sidebarCommentText .richText) span{ - color:var(--comment-fg-color) !important; - } +:is(.commentPopupText, .sidebarCommentText .richText) span { + color: var(--comment-fg-color) !important; +} -:root{ - --editor-toolbar-vert-offset:6px; - --outline-width:2px; - --outline-color:#0060df; - --outline-around-width:1px; - --outline-around-color:#f0f0f4; - --hover-outline-around-color:var(--outline-around-color); - --focus-outline:solid var(--outline-width) var(--outline-color); - --unfocus-outline:solid var(--outline-width) transparent; - --focus-outline-around:solid var(--outline-around-width) var(--outline-around-color); - --hover-outline-color:#8f8f9d; - --hover-outline:solid var(--outline-width) var(--hover-outline-color); - --hover-outline-around:solid var(--outline-around-width) var(--hover-outline-around-color); - --freetext-line-height:1.35; - --freetext-padding:2px; - --resizer-bg-color:var(--outline-color); - --resizer-size:6px; - --resizer-shift:calc( +:root { + --editor-toolbar-vert-offset: 6px; + --outline-width: 2px; + --outline-color: #0060df; + --outline-around-width: 1px; + --outline-around-color: #f0f0f4; + --hover-outline-around-color: var(--outline-around-color); + --focus-outline: solid var(--outline-width) var(--outline-color); + --unfocus-outline: solid var(--outline-width) transparent; + --focus-outline-around: solid var(--outline-around-width) + var(--outline-around-color); + --hover-outline-color: #8f8f9d; + --hover-outline: solid var(--outline-width) var(--hover-outline-color); + --hover-outline-around: solid var(--outline-around-width) + var(--hover-outline-around-color); + --freetext-line-height: 1.35; + --freetext-padding: 2px; + --resizer-bg-color: var(--outline-color); + --resizer-size: 6px; + --resizer-shift: calc( 0px - (var(--outline-width) + var(--resizer-size)) / 2 - var(--outline-around-width) ); - --editorFreeText-editing-cursor:text; - --editorInk-editing-cursor:url(images/cursor-editorInk.svg) 0 16, pointer; - --editorHighlight-editing-cursor:url(images/cursor-editorTextHighlight.svg) 24 24, text; - --editorFreeHighlight-editing-cursor:url(images/cursor-editorFreeHighlight.svg) 1 18, pointer; + --editorFreeText-editing-cursor: text; + --editorInk-editing-cursor: url(images/cursor-editorInk.svg) 0 16, pointer; + --editorHighlight-editing-cursor: + url(images/cursor-editorTextHighlight.svg) 24 24, text; + --editorFreeHighlight-editing-cursor: + url(images/cursor-editorFreeHighlight.svg) 1 18, pointer; - --new-alt-text-warning-image:url(images/altText_warning.svg); + --new-alt-text-warning-image: url(images/altText_warning.svg); } -.textLayer.highlighting{ - cursor:var(--editorFreeHighlight-editing-cursor); - } +.textLayer.highlighting { + cursor: var(--editorFreeHighlight-editing-cursor); +} -.textLayer.highlighting:not(.free) span{ - cursor:var(--editorHighlight-editing-cursor); - } +.textLayer.highlighting:not(.free) span { + cursor: var(--editorHighlight-editing-cursor); +} -[role="img"]:is(.textLayer.highlighting:not(.free) span){ - cursor:var(--editorFreeHighlight-editing-cursor); - } +[role='img']:is(.textLayer.highlighting:not(.free) span) { + cursor: var(--editorFreeHighlight-editing-cursor); +} -.textLayer.highlighting.free span{ - cursor:var(--editorFreeHighlight-editing-cursor); - } +.textLayer.highlighting.free span { + cursor: var(--editorFreeHighlight-editing-cursor); +} .page:has(.annotationEditorLayer.nonEditing) .annotationLayer - .editorAnnotation{ - position:absolute; - pointer-events:none; + .editorAnnotation { + position: absolute; + pointer-events: none; } -:is(#viewerContainer.pdfPresentationMode:fullscreen,.annotationEditorLayer.disabled) .noAltTextBadge{ - display:none !important; - } +:is( + #viewerContainer.pdfPresentationMode:fullscreen, + .annotationEditorLayer.disabled + ) + .noAltTextBadge { + display: none !important; +} -@media (min-resolution: 1.1dppx){ - :root{ - --editorFreeText-editing-cursor:url(images/cursor-editorFreeText.svg) 0 16, text; +@media (min-resolution: 1.1dppx) { + :root { + --editorFreeText-editing-cursor: + url(images/cursor-editorFreeText.svg) 0 16, text; } } -@media screen and (forced-colors: active){ - :root{ - --outline-color:CanvasText; - --outline-around-color:ButtonFace; - --resizer-bg-color:ButtonText; - --hover-outline-color:Highlight; - --hover-outline-around-color:SelectedItemText; +@media screen and (forced-colors: active) { + :root { + --outline-color: CanvasText; + --outline-around-color: ButtonFace; + --resizer-bg-color: ButtonText; + --hover-outline-color: Highlight; + --hover-outline-around-color: SelectedItemText; } } -[data-editor-rotation="90"]{ - transform:rotate(90deg); +[data-editor-rotation='90'] { + transform: rotate(90deg); } -[data-editor-rotation="180"]{ - transform:rotate(180deg); +[data-editor-rotation='180'] { + transform: rotate(180deg); } -[data-editor-rotation="270"]{ - transform:rotate(270deg); +[data-editor-rotation='270'] { + transform: rotate(270deg); } -.annotationEditorLayer{ - background:transparent; - position:absolute; - inset:0; - font-size:calc(100px * var(--total-scale-factor)); - transform-origin:0 0; - cursor:auto; +.annotationEditorLayer { + background: transparent; + position: absolute; + inset: 0; + font-size: calc(100px * var(--total-scale-factor)); + transform-origin: 0 0; + cursor: auto; } -.annotationEditorLayer .selectedEditor{ - z-index:100000 !important; - } - -.annotationEditorLayer.drawing *{ - pointer-events:none !important; - } - -.annotationEditorLayer.getElements{ - pointer-events:auto !important; - } - -.annotationEditorLayer.getElements > div{ - pointer-events:auto !important; - } - -.annotationEditorLayer.waiting{ - content:""; - cursor:wait; - position:absolute; - inset:0; - width:100%; - height:100%; +.annotationEditorLayer .selectedEditor { + z-index: 100000 !important; } -.annotationEditorLayer.disabled{ - pointer-events:none; +.annotationEditorLayer.drawing * { + pointer-events: none !important; } -.annotationEditorLayer.disabled.highlightEditing :is(.freeTextEditor,.inkEditor,.stampEditor,.signatureEditor,.commentPopup){ - pointer-events:auto; - } - -.annotationEditorLayer.freetextEditing{ - cursor:var(--editorFreeText-editing-cursor); +.annotationEditorLayer.getElements { + pointer-events: auto !important; } -.annotationEditorLayer.inkEditing{ - cursor:var(--editorInk-editing-cursor); +.annotationEditorLayer.getElements > div { + pointer-events: auto !important; } -.annotationEditorLayer .draw{ - box-sizing:border-box; +.annotationEditorLayer.waiting { + content: ''; + cursor: wait; + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.annotationEditorLayer.disabled { + pointer-events: none; +} + +.annotationEditorLayer.disabled.highlightEditing + :is( + .freeTextEditor, + .inkEditor, + .stampEditor, + .signatureEditor, + .commentPopup + ) { + pointer-events: auto; +} + +.annotationEditorLayer.freetextEditing { + cursor: var(--editorFreeText-editing-cursor); +} + +.annotationEditorLayer.inkEditing { + cursor: var(--editorInk-editing-cursor); +} + +.annotationEditorLayer .draw { + box-sizing: border-box; } .annotationEditorLayer - :is(.freeTextEditor, .inkEditor, .stampEditor, .signatureEditor){ - position:absolute; - background:transparent; - z-index:1; - transform-origin:0 0; - cursor:auto; - max-width:100%; - max-height:100%; - border:var(--unfocus-outline); + :is(.freeTextEditor, .inkEditor, .stampEditor, .signatureEditor) { + position: absolute; + background: transparent; + z-index: 1; + transform-origin: 0 0; + cursor: auto; + max-width: 100%; + max-height: 100%; + border: var(--unfocus-outline); } -.draggable.selectedEditor:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.signatureEditor)){ - cursor:move; - } - -.selectedEditor:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.signatureEditor)){ - border:var(--focus-outline); - outline:var(--focus-outline-around); - } - -.selectedEditor:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.signatureEditor))::before{ - content:""; - position:absolute; - inset:0; - border:var(--focus-outline-around); - pointer-events:none; - } - -:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.signatureEditor)):hover:not(.selectedEditor){ - border:var(--hover-outline); - outline:var(--hover-outline-around); - } - -:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.signatureEditor)):hover:not(.selectedEditor)::before{ - content:""; - position:absolute; - inset:0; - border:var(--focus-outline-around); - } - -:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor,.signatureEditor),.textLayer) .editToolbar{ - --editor-toolbar-delete-image:url(images/editor-toolbar-delete.svg); - --csstools-light-dark-toggle--105:var(--csstools-color-scheme--light) #2b2a33; - --editor-toolbar-bg-color:var(--csstools-light-dark-toggle--105, #f0f0f4); - --editor-toolbar-highlight-image:url(images/toolbarButton-editorHighlight.svg); - --csstools-light-dark-toggle--106:var(--csstools-color-scheme--light) #fbfbfe; - --editor-toolbar-fg-color:var(--csstools-light-dark-toggle--106, #2e2e56); - --editor-toolbar-border-color:#8f8f9d; - --editor-toolbar-hover-border-color:var(--editor-toolbar-border-color); - --csstools-light-dark-toggle--107:var(--csstools-color-scheme--light) #52525e; - --editor-toolbar-hover-bg-color:var(--csstools-light-dark-toggle--107, #e0e0e6); - --editor-toolbar-hover-fg-color:var(--editor-toolbar-fg-color); - --editor-toolbar-hover-outline:none; - --csstools-light-dark-toggle--108:var(--csstools-color-scheme--light) #0df; - --editor-toolbar-focus-outline-color:var(--csstools-light-dark-toggle--108, #0060df); - --editor-toolbar-shadow:0 2px 6px 0 rgb(58 57 68 / 0.2); - --editor-toolbar-height:28px; - --editor-toolbar-padding:2px; - --csstools-light-dark-toggle--109:var(--csstools-color-scheme--light) #54ffbd; - --alt-text-done-color:var(--csstools-light-dark-toggle--109, #2ac3a2); - --csstools-light-dark-toggle--110:var(--csstools-color-scheme--light) #80ebff; - --alt-text-warning-color:var(--csstools-light-dark-toggle--110, #0090ed); - --alt-text-hover-done-color:var(--alt-text-done-color); - --alt-text-hover-warning-color:var(--alt-text-warning-color); - } - -@supports (color: light-dark(red, red)){ -:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor,.signatureEditor),.textLayer) .editToolbar{ - --editor-toolbar-bg-color:light-dark(#f0f0f4, #2b2a33); - --editor-toolbar-fg-color:light-dark(#2e2e56, #fbfbfe); - --editor-toolbar-hover-bg-color:light-dark(#e0e0e6, #52525e); - --editor-toolbar-focus-outline-color:light-dark(#0060df, #0df); - --alt-text-done-color:light-dark(#2ac3a2, #54ffbd); - --alt-text-warning-color:light-dark(#0090ed, #80ebff); - } +.draggable.selectedEditor:is( + .annotationEditorLayer + :is(.freeTextEditor, .inkEditor, .stampEditor, .signatureEditor) + ) { + cursor: move; } -@supports not (color: light-dark(tan, tan)){ - -:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor,.signatureEditor),.textLayer) .editToolbar) *{ - --csstools-light-dark-toggle--105:var(--csstools-color-scheme--light) #2b2a33; - --editor-toolbar-bg-color:var(--csstools-light-dark-toggle--105, #f0f0f4); - --csstools-light-dark-toggle--106:var(--csstools-color-scheme--light) #fbfbfe; - --editor-toolbar-fg-color:var(--csstools-light-dark-toggle--106, #2e2e56); - --csstools-light-dark-toggle--107:var(--csstools-color-scheme--light) #52525e; - --editor-toolbar-hover-bg-color:var(--csstools-light-dark-toggle--107, #e0e0e6); - --csstools-light-dark-toggle--108:var(--csstools-color-scheme--light) #0df; - --editor-toolbar-focus-outline-color:var(--csstools-light-dark-toggle--108, #0060df); - --csstools-light-dark-toggle--109:var(--csstools-color-scheme--light) #54ffbd; - --alt-text-done-color:var(--csstools-light-dark-toggle--109, #2ac3a2); - --csstools-light-dark-toggle--110:var(--csstools-color-scheme--light) #80ebff; - --alt-text-warning-color:var(--csstools-light-dark-toggle--110, #0090ed); - } +.selectedEditor:is( + .annotationEditorLayer + :is(.freeTextEditor, .inkEditor, .stampEditor, .signatureEditor) +) { + border: var(--focus-outline); + outline: var(--focus-outline-around); } -@media screen and (forced-colors: active){ - -:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor,.signatureEditor),.textLayer) .editToolbar{ - --editor-toolbar-bg-color:ButtonFace; - --editor-toolbar-fg-color:ButtonText; - --editor-toolbar-border-color:ButtonText; - --editor-toolbar-hover-border-color:AccentColor; - --editor-toolbar-hover-bg-color:ButtonFace; - --editor-toolbar-hover-fg-color:AccentColor; - --editor-toolbar-hover-outline:2px solid var(--editor-toolbar-hover-border-color); - --editor-toolbar-focus-outline-color:ButtonBorder; - --editor-toolbar-shadow:none; - --alt-text-done-color:var(--editor-toolbar-fg-color); - --alt-text-warning-color:var(--editor-toolbar-fg-color); - --alt-text-hover-done-color:var(--editor-toolbar-hover-fg-color); - --alt-text-hover-warning-color:var(--editor-toolbar-hover-fg-color); - } - } - -:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor,.signatureEditor),.textLayer) .editToolbar{ - - display:flex; - width:-moz-fit-content; - width:fit-content; - height:var(--editor-toolbar-height); - flex-direction:column; - justify-content:center; - align-items:center; - cursor:default; - pointer-events:auto; - box-sizing:content-box; - padding:var(--editor-toolbar-padding); - - position:absolute; - inset-inline-end:0; - inset-block-start:calc(100% + var(--editor-toolbar-vert-offset)); - - border-radius:6px; - background-color:var(--editor-toolbar-bg-color); - border:1px solid var(--editor-toolbar-border-color); - box-shadow:var(--editor-toolbar-shadow); - } - -.hidden:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor,.signatureEditor),.textLayer) .editToolbar){ - display:none; - } - -:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor,.signatureEditor),.textLayer) .editToolbar):has(:focus-visible){ - border-color:transparent; - } - -[dir="ltr"] :is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor,.signatureEditor),.textLayer) .editToolbar){ - transform-origin:100% 0; - } - -[dir="rtl"] :is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor,.signatureEditor),.textLayer) .editToolbar){ - transform-origin:0 0; - } - -:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor,.signatureEditor),.textLayer) .editToolbar) .buttons{ - display:flex; - justify-content:center; - align-items:center; - gap:0; - height:100%; - } - -:is(:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor,.signatureEditor),.textLayer) .editToolbar) .buttons) button{ - padding:0; - } - -:is(:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor,.signatureEditor),.textLayer) .editToolbar) .buttons) .divider{ - width:0; - height:calc( - 2 * var(--editor-toolbar-padding) + var(--editor-toolbar-height) - ); - border-left:1px solid var(--editor-toolbar-border-color); - border-right:none; - display:inline-block; - margin-inline:2px; - } - -:is(:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor,.signatureEditor),.textLayer) .editToolbar) .buttons) .basic{ - width:var(--editor-toolbar-height); - } - -:is(:is(:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor,.signatureEditor),.textLayer) .editToolbar) .buttons) .basic)::before{ - content:""; - -webkit-mask-repeat:no-repeat; - mask-repeat:no-repeat; - -webkit-mask-position:center; - mask-position:center; - display:inline-block; - background-color:var(--editor-toolbar-fg-color); - width:100%; - height:100%; - } - -:is(:is(:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor,.signatureEditor),.textLayer) .editToolbar) .buttons) .basic):hover::before{ - background-color:var(--editor-toolbar-hover-fg-color); - } - -.highlightButton:is(:is(:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor,.signatureEditor),.textLayer) .editToolbar) .buttons) .basic)::before{ - -webkit-mask-image:var(--editor-toolbar-highlight-image); - mask-image:var(--editor-toolbar-highlight-image); - } - -.commentButton:is(:is(:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor,.signatureEditor),.textLayer) .editToolbar) .buttons) .basic)::before{ - -webkit-mask-image:var(--comment-edit-button-icon); - mask-image:var(--comment-edit-button-icon); - } - -.deleteButton:is(:is(:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor,.signatureEditor),.textLayer) .editToolbar) .buttons) .basic)::before{ - -webkit-mask-image:var(--editor-toolbar-delete-image); - mask-image:var(--editor-toolbar-delete-image); - } - -:is(:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor,.signatureEditor),.textLayer) .editToolbar) .buttons) > *{ - height:var(--editor-toolbar-height); - } - -:is(:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor,.signatureEditor),.textLayer) .editToolbar) .buttons) > :not(.divider){ - border:none; - background-color:transparent; - cursor:pointer; - } - -:is(:is(:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor,.signatureEditor),.textLayer) .editToolbar) .buttons) > :not(.divider)):hover{ - border-radius:2px; - background-color:var(--editor-toolbar-hover-bg-color); - color:var(--editor-toolbar-hover-fg-color); - outline:var(--editor-toolbar-hover-outline); - outline-offset:1px; - } - -:is(:is(:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor,.signatureEditor),.textLayer) .editToolbar) .buttons) > :not(.divider)):hover:active{ - outline:none; - } - -:is(:is(:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor,.signatureEditor),.textLayer) .editToolbar) .buttons) > :not(.divider)):focus-visible{ - border-radius:2px; - outline:2px solid var(--editor-toolbar-focus-outline-color); - } - -:is(:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor,.signatureEditor),.textLayer) .editToolbar) .buttons) .altText{ - --alt-text-add-image:url(images/altText_add.svg); - --alt-text-done-image:url(images/altText_done.svg); - - display:flex; - align-items:center; - justify-content:center; - width:-moz-max-content; - width:max-content; - padding-inline:8px; - pointer-events:all; - font:menu; - font-weight:590; - font-size:12px; - color:var(--editor-toolbar-fg-color); - } - -:is(:is(:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor,.signatureEditor),.textLayer) .editToolbar) .buttons) .altText):disabled{ - pointer-events:none; - } - -:is(:is(:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor,.signatureEditor),.textLayer) .editToolbar) .buttons) .altText)::before{ - content:""; - -webkit-mask-image:var(--alt-text-add-image); - mask-image:var(--alt-text-add-image); - -webkit-mask-repeat:no-repeat; - mask-repeat:no-repeat; - -webkit-mask-position:center; - mask-position:center; - display:inline-block; - width:12px; - height:13px; - background-color:var(--editor-toolbar-fg-color); - margin-inline-end:4px; - } - -:is(:is(:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor,.signatureEditor),.textLayer) .editToolbar) .buttons) .altText):hover::before{ - background-color:var(--editor-toolbar-hover-fg-color); - } - -.done:is(:is(:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor,.signatureEditor),.textLayer) .editToolbar) .buttons) .altText)::before{ - -webkit-mask-image:var(--alt-text-done-image); - mask-image:var(--alt-text-done-image); - } - -.new:is(:is(:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor,.signatureEditor),.textLayer) .editToolbar) .buttons) .altText)::before{ - width:16px; - height:16px; - -webkit-mask-image:var(--new-alt-text-warning-image); - mask-image:var(--new-alt-text-warning-image); - background-color:var(--alt-text-warning-color); - -webkit-mask-size:cover; - mask-size:cover; - } - -.new:is(:is(:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor,.signatureEditor),.textLayer) .editToolbar) .buttons) .altText):hover::before{ - background-color:var(--alt-text-hover-warning-color); - } - -.new.done:is(:is(:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor,.signatureEditor),.textLayer) .editToolbar) .buttons) .altText)::before{ - -webkit-mask-image:var(--alt-text-done-image); - mask-image:var(--alt-text-done-image); - background-color:var(--alt-text-done-color); - } - -.new.done:is(:is(:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor,.signatureEditor),.textLayer) .editToolbar) .buttons) .altText):hover::before{ - background-color:var(--alt-text-hover-done-color); - } - -:is(:is(:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor,.signatureEditor),.textLayer) .editToolbar) .buttons) .altText) .tooltip{ - display:none; - word-wrap:anywhere; - } - -.show:is(:is(:is(:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor,.signatureEditor),.textLayer) .editToolbar) .buttons) .altText) .tooltip){ - --csstools-light-dark-toggle--111:var(--csstools-color-scheme--light) #1c1b22; - --alt-text-tooltip-bg:var(--csstools-light-dark-toggle--111, #f0f0f4); - --csstools-light-dark-toggle--112:var(--csstools-color-scheme--light) #fbfbfe; - --alt-text-tooltip-fg:var(--csstools-light-dark-toggle--112, #15141a); - --alt-text-tooltip-border:#8f8f9d; - --csstools-light-dark-toggle--113:var(--csstools-color-scheme--light) #15141a; - --alt-text-tooltip-shadow:0 2px 6px 0 var(--csstools-light-dark-toggle--113, rgb(58 57 68 / 0.2)); - } - -@supports (color: light-dark(red, red)){ -.show:is(:is(:is(:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor,.signatureEditor),.textLayer) .editToolbar) .buttons) .altText) .tooltip){ - --alt-text-tooltip-bg:light-dark(#f0f0f4, #1c1b22); - --alt-text-tooltip-fg:light-dark(#15141a, #fbfbfe); - } +.selectedEditor:is( + .annotationEditorLayer + :is(.freeTextEditor, .inkEditor, .stampEditor, .signatureEditor) + )::before { + content: ''; + position: absolute; + inset: 0; + border: var(--focus-outline-around); + pointer-events: none; } -@supports (color: light-dark(red, red)) and (color: rgb(0 0 0 / 0)){ -.show:is(:is(:is(:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor,.signatureEditor),.textLayer) .editToolbar) .buttons) .altText) .tooltip){ - --alt-text-tooltip-shadow:0 2px 6px 0 light-dark(rgb(58 57 68 / 0.2), #15141a); - } +:is( + .annotationEditorLayer + :is(.freeTextEditor, .inkEditor, .stampEditor, .signatureEditor) + ):hover:not(.selectedEditor) { + border: var(--hover-outline); + outline: var(--hover-outline-around); } -@supports not (color: light-dark(tan, tan)){ - -.show:is(:is(:is(:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor,.signatureEditor),.textLayer) .editToolbar) .buttons) .altText) .tooltip) *{ - --csstools-light-dark-toggle--111:var(--csstools-color-scheme--light) #1c1b22; - --alt-text-tooltip-bg:var(--csstools-light-dark-toggle--111, #f0f0f4); - --csstools-light-dark-toggle--112:var(--csstools-color-scheme--light) #fbfbfe; - --alt-text-tooltip-fg:var(--csstools-light-dark-toggle--112, #15141a); - --csstools-light-dark-toggle--113:var(--csstools-color-scheme--light) #15141a; - --alt-text-tooltip-shadow:0 2px 6px 0 var(--csstools-light-dark-toggle--113, rgb(58 57 68 / 0.2)); - } +:is( + .annotationEditorLayer + :is(.freeTextEditor, .inkEditor, .stampEditor, .signatureEditor) + ):hover:not(.selectedEditor)::before { + content: ''; + position: absolute; + inset: 0; + border: var(--focus-outline-around); } -@media screen and (forced-colors: active){ - -.show:is(:is(:is(:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor,.signatureEditor),.textLayer) .editToolbar) .buttons) .altText) .tooltip){ - --alt-text-tooltip-bg:Canvas; - --alt-text-tooltip-fg:CanvasText; - --alt-text-tooltip-border:CanvasText; - --alt-text-tooltip-shadow:none; - } - } - -.show:is(:is(:is(:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor,.signatureEditor),.textLayer) .editToolbar) .buttons) .altText) .tooltip){ - - display:inline-flex; - flex-direction:column; - align-items:center; - justify-content:center; - position:absolute; - top:calc(100% + 2px); - inset-inline-start:0; - padding-block:2px 3px; - padding-inline:3px; - max-width:300px; - width:-moz-max-content; - width:max-content; - height:auto; - font-size:12px; - - border:0.5px solid var(--alt-text-tooltip-border); - background:var(--alt-text-tooltip-bg); - box-shadow:var(--alt-text-tooltip-shadow); - color:var(--alt-text-tooltip-fg); - - pointer-events:none; - } - -:is(:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor,.signatureEditor),.textLayer) .editToolbar) .buttons) .comment{ - width:var(--editor-toolbar-height); - } - -:is(:is(:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor,.signatureEditor),.textLayer) .editToolbar) .buttons) .comment)::before{ - content:""; - -webkit-mask-image:var(--comment-edit-button-icon); - mask-image:var(--comment-edit-button-icon); - -webkit-mask-repeat:no-repeat; - mask-repeat:no-repeat; - -webkit-mask-position:center; - mask-position:center; - display:inline-block; - background-color:var(--editor-toolbar-fg-color); - width:100%; - height:100%; - } - -.annotationEditorLayer .freeTextEditor{ - padding:calc(var(--freetext-padding) * var(--total-scale-factor)); - width:auto; - height:auto; - touch-action:none; -} - -.annotationEditorLayer .freeTextEditor .internal{ - background:transparent; - border:none; - inset:0; - overflow:visible; - white-space:nowrap; - font:10px sans-serif; - line-height:var(--freetext-line-height); - text-align:start; - -webkit-user-select:none; - -moz-user-select:none; - user-select:none; -} - -.annotationEditorLayer .freeTextEditor .overlay{ - position:absolute; - display:none; - background:transparent; - inset:0; - width:100%; - height:100%; -} - -.annotationEditorLayer freeTextEditor .overlay.enabled{ - display:block; -} - -.annotationEditorLayer .freeTextEditor .internal:empty::before{ - content:attr(default-content); - color:gray; -} - -.annotationEditorLayer .freeTextEditor .internal:focus{ - outline:none; - -webkit-user-select:auto; - -moz-user-select:auto; - user-select:auto; -} - -.annotationEditorLayer .inkEditor{ - width:100%; - height:100%; -} - -.annotationEditorLayer .inkEditor.editing{ - cursor:inherit; -} - -.annotationEditorLayer .inkEditor .inkEditorCanvas{ - position:absolute; - inset:0; - width:100%; - height:100%; - touch-action:none; -} - -.annotationEditorLayer .stampEditor{ - width:auto; - height:auto; -} - -:is(.annotationEditorLayer .stampEditor) canvas{ - position:absolute; - width:100%; - height:100%; - margin:0; - top:0; - left:0; - } - -:is(.annotationEditorLayer .stampEditor) .noAltTextBadge{ - --csstools-light-dark-toggle--114:var(--csstools-color-scheme--light) #52525e; - --no-alt-text-badge-border-color:var(--csstools-light-dark-toggle--114, #f0f0f4); - --csstools-light-dark-toggle--115:var(--csstools-color-scheme--light) #fbfbfe; - --no-alt-text-badge-bg-color:var(--csstools-light-dark-toggle--115, #cfcfd8); - --csstools-light-dark-toggle--116:var(--csstools-color-scheme--light) #15141a; - --no-alt-text-badge-fg-color:var(--csstools-light-dark-toggle--116, #5b5b66); - } - -@supports (color: light-dark(red, red)){ -:is(.annotationEditorLayer .stampEditor) .noAltTextBadge{ - --no-alt-text-badge-border-color:light-dark(#f0f0f4, #52525e); - --no-alt-text-badge-bg-color:light-dark(#cfcfd8, #fbfbfe); - --no-alt-text-badge-fg-color:light-dark(#5b5b66, #15141a); - } -} - -@supports not (color: light-dark(tan, tan)){ - -:is(:is(.annotationEditorLayer .stampEditor) .noAltTextBadge) *{ - --csstools-light-dark-toggle--114:var(--csstools-color-scheme--light) #52525e; - --no-alt-text-badge-border-color:var(--csstools-light-dark-toggle--114, #f0f0f4); - --csstools-light-dark-toggle--115:var(--csstools-color-scheme--light) #fbfbfe; - --no-alt-text-badge-bg-color:var(--csstools-light-dark-toggle--115, #cfcfd8); - --csstools-light-dark-toggle--116:var(--csstools-color-scheme--light) #15141a; - --no-alt-text-badge-fg-color:var(--csstools-light-dark-toggle--116, #5b5b66); - } -} - -@media screen and (forced-colors: active){ - -:is(.annotationEditorLayer .stampEditor) .noAltTextBadge{ - --no-alt-text-badge-border-color:ButtonText; - --no-alt-text-badge-bg-color:ButtonFace; - --no-alt-text-badge-fg-color:ButtonText; - } - } - -:is(.annotationEditorLayer .stampEditor) .noAltTextBadge{ - - position:absolute; - inset-inline-end:5px; - inset-block-end:5px; - display:inline-flex; - width:32px; - height:32px; - padding:3px; - justify-content:center; - align-items:center; - pointer-events:none; - z-index:1; - - border-radius:2px; - border:1px solid var(--no-alt-text-badge-border-color); - background:var(--no-alt-text-badge-bg-color); - } - -:is(:is(.annotationEditorLayer .stampEditor) .noAltTextBadge)::before{ - content:""; - display:inline-block; - width:16px; - height:16px; - -webkit-mask-image:var(--new-alt-text-warning-image); - mask-image:var(--new-alt-text-warning-image); - -webkit-mask-size:cover; - mask-size:cover; - background-color:var(--no-alt-text-badge-fg-color); - } - -:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.signatureEditor)) > .resizers{ - position:absolute; - inset:0; - z-index:1; - } - -.hidden:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.signatureEditor)) > .resizers){ - display:none; - } - -:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.signatureEditor)) > .resizers) > .resizer{ - width:var(--resizer-size); - height:var(--resizer-size); - background:content-box var(--resizer-bg-color); - border:var(--focus-outline-around); - border-radius:2px; - position:absolute; - } - -.topLeft:is(:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.signatureEditor)) > .resizers) > .resizer){ - top:var(--resizer-shift); - left:var(--resizer-shift); - } - -.topMiddle:is(:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.signatureEditor)) > .resizers) > .resizer){ - top:var(--resizer-shift); - left:calc(50% + var(--resizer-shift)); - } - -.topRight:is(:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.signatureEditor)) > .resizers) > .resizer){ - top:var(--resizer-shift); - right:var(--resizer-shift); - } - -.middleRight:is(:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.signatureEditor)) > .resizers) > .resizer){ - top:calc(50% + var(--resizer-shift)); - right:var(--resizer-shift); - } - -.bottomRight:is(:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.signatureEditor)) > .resizers) > .resizer){ - bottom:var(--resizer-shift); - right:var(--resizer-shift); - } - -.bottomMiddle:is(:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.signatureEditor)) > .resizers) > .resizer){ - bottom:var(--resizer-shift); - left:calc(50% + var(--resizer-shift)); - } - -.bottomLeft:is(:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.signatureEditor)) > .resizers) > .resizer){ - bottom:var(--resizer-shift); - left:var(--resizer-shift); - } - -.middleLeft:is(:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.signatureEditor)) > .resizers) > .resizer){ - top:calc(50% + var(--resizer-shift)); - left:var(--resizer-shift); - } - -.topLeft:is(:is(.annotationEditorLayer[data-main-rotation="0"] :is([data-editor-rotation="0"],[data-editor-rotation="180"]),.annotationEditorLayer[data-main-rotation="90"] :is([data-editor-rotation="270"],[data-editor-rotation="90"]),.annotationEditorLayer[data-main-rotation="180"] :is([data-editor-rotation="180"],[data-editor-rotation="0"]),.annotationEditorLayer[data-main-rotation="270"] :is([data-editor-rotation="90"],[data-editor-rotation="270"])) > .resizers > .resizer),.bottomRight:is(:is(.annotationEditorLayer[data-main-rotation="0"] :is([data-editor-rotation="0"],[data-editor-rotation="180"]),.annotationEditorLayer[data-main-rotation="90"] :is([data-editor-rotation="270"],[data-editor-rotation="90"]),.annotationEditorLayer[data-main-rotation="180"] :is([data-editor-rotation="180"],[data-editor-rotation="0"]),.annotationEditorLayer[data-main-rotation="270"] :is([data-editor-rotation="90"],[data-editor-rotation="270"])) > .resizers > .resizer){ - cursor:nwse-resize; - } - -.topMiddle:is(:is(.annotationEditorLayer[data-main-rotation="0"] :is([data-editor-rotation="0"],[data-editor-rotation="180"]),.annotationEditorLayer[data-main-rotation="90"] :is([data-editor-rotation="270"],[data-editor-rotation="90"]),.annotationEditorLayer[data-main-rotation="180"] :is([data-editor-rotation="180"],[data-editor-rotation="0"]),.annotationEditorLayer[data-main-rotation="270"] :is([data-editor-rotation="90"],[data-editor-rotation="270"])) > .resizers > .resizer),.bottomMiddle:is(:is(.annotationEditorLayer[data-main-rotation="0"] :is([data-editor-rotation="0"],[data-editor-rotation="180"]),.annotationEditorLayer[data-main-rotation="90"] :is([data-editor-rotation="270"],[data-editor-rotation="90"]),.annotationEditorLayer[data-main-rotation="180"] :is([data-editor-rotation="180"],[data-editor-rotation="0"]),.annotationEditorLayer[data-main-rotation="270"] :is([data-editor-rotation="90"],[data-editor-rotation="270"])) > .resizers > .resizer){ - cursor:ns-resize; - } - -.topRight:is(:is(.annotationEditorLayer[data-main-rotation="0"] :is([data-editor-rotation="0"],[data-editor-rotation="180"]),.annotationEditorLayer[data-main-rotation="90"] :is([data-editor-rotation="270"],[data-editor-rotation="90"]),.annotationEditorLayer[data-main-rotation="180"] :is([data-editor-rotation="180"],[data-editor-rotation="0"]),.annotationEditorLayer[data-main-rotation="270"] :is([data-editor-rotation="90"],[data-editor-rotation="270"])) > .resizers > .resizer),.bottomLeft:is(:is(.annotationEditorLayer[data-main-rotation="0"] :is([data-editor-rotation="0"],[data-editor-rotation="180"]),.annotationEditorLayer[data-main-rotation="90"] :is([data-editor-rotation="270"],[data-editor-rotation="90"]),.annotationEditorLayer[data-main-rotation="180"] :is([data-editor-rotation="180"],[data-editor-rotation="0"]),.annotationEditorLayer[data-main-rotation="270"] :is([data-editor-rotation="90"],[data-editor-rotation="270"])) > .resizers > .resizer){ - cursor:nesw-resize; - } - -.middleRight:is(:is(.annotationEditorLayer[data-main-rotation="0"] :is([data-editor-rotation="0"],[data-editor-rotation="180"]),.annotationEditorLayer[data-main-rotation="90"] :is([data-editor-rotation="270"],[data-editor-rotation="90"]),.annotationEditorLayer[data-main-rotation="180"] :is([data-editor-rotation="180"],[data-editor-rotation="0"]),.annotationEditorLayer[data-main-rotation="270"] :is([data-editor-rotation="90"],[data-editor-rotation="270"])) > .resizers > .resizer),.middleLeft:is(:is(.annotationEditorLayer[data-main-rotation="0"] :is([data-editor-rotation="0"],[data-editor-rotation="180"]),.annotationEditorLayer[data-main-rotation="90"] :is([data-editor-rotation="270"],[data-editor-rotation="90"]),.annotationEditorLayer[data-main-rotation="180"] :is([data-editor-rotation="180"],[data-editor-rotation="0"]),.annotationEditorLayer[data-main-rotation="270"] :is([data-editor-rotation="90"],[data-editor-rotation="270"])) > .resizers > .resizer){ - cursor:ew-resize; - } - -.topLeft:is(:is(.annotationEditorLayer[data-main-rotation="0"] :is([data-editor-rotation="90"],[data-editor-rotation="270"]),.annotationEditorLayer[data-main-rotation="90"] :is([data-editor-rotation="0"],[data-editor-rotation="180"]),.annotationEditorLayer[data-main-rotation="180"] :is([data-editor-rotation="270"],[data-editor-rotation="90"]),.annotationEditorLayer[data-main-rotation="270"] :is([data-editor-rotation="180"],[data-editor-rotation="0"])) > .resizers > .resizer),.bottomRight:is(:is(.annotationEditorLayer[data-main-rotation="0"] :is([data-editor-rotation="90"],[data-editor-rotation="270"]),.annotationEditorLayer[data-main-rotation="90"] :is([data-editor-rotation="0"],[data-editor-rotation="180"]),.annotationEditorLayer[data-main-rotation="180"] :is([data-editor-rotation="270"],[data-editor-rotation="90"]),.annotationEditorLayer[data-main-rotation="270"] :is([data-editor-rotation="180"],[data-editor-rotation="0"])) > .resizers > .resizer){ - cursor:nesw-resize; - } - -.topMiddle:is(:is(.annotationEditorLayer[data-main-rotation="0"] :is([data-editor-rotation="90"],[data-editor-rotation="270"]),.annotationEditorLayer[data-main-rotation="90"] :is([data-editor-rotation="0"],[data-editor-rotation="180"]),.annotationEditorLayer[data-main-rotation="180"] :is([data-editor-rotation="270"],[data-editor-rotation="90"]),.annotationEditorLayer[data-main-rotation="270"] :is([data-editor-rotation="180"],[data-editor-rotation="0"])) > .resizers > .resizer),.bottomMiddle:is(:is(.annotationEditorLayer[data-main-rotation="0"] :is([data-editor-rotation="90"],[data-editor-rotation="270"]),.annotationEditorLayer[data-main-rotation="90"] :is([data-editor-rotation="0"],[data-editor-rotation="180"]),.annotationEditorLayer[data-main-rotation="180"] :is([data-editor-rotation="270"],[data-editor-rotation="90"]),.annotationEditorLayer[data-main-rotation="270"] :is([data-editor-rotation="180"],[data-editor-rotation="0"])) > .resizers > .resizer){ - cursor:ew-resize; - } - -.topRight:is(:is(.annotationEditorLayer[data-main-rotation="0"] :is([data-editor-rotation="90"],[data-editor-rotation="270"]),.annotationEditorLayer[data-main-rotation="90"] :is([data-editor-rotation="0"],[data-editor-rotation="180"]),.annotationEditorLayer[data-main-rotation="180"] :is([data-editor-rotation="270"],[data-editor-rotation="90"]),.annotationEditorLayer[data-main-rotation="270"] :is([data-editor-rotation="180"],[data-editor-rotation="0"])) > .resizers > .resizer),.bottomLeft:is(:is(.annotationEditorLayer[data-main-rotation="0"] :is([data-editor-rotation="90"],[data-editor-rotation="270"]),.annotationEditorLayer[data-main-rotation="90"] :is([data-editor-rotation="0"],[data-editor-rotation="180"]),.annotationEditorLayer[data-main-rotation="180"] :is([data-editor-rotation="270"],[data-editor-rotation="90"]),.annotationEditorLayer[data-main-rotation="270"] :is([data-editor-rotation="180"],[data-editor-rotation="0"])) > .resizers > .resizer){ - cursor:nwse-resize; - } - -.middleRight:is(:is(.annotationEditorLayer[data-main-rotation="0"] :is([data-editor-rotation="90"],[data-editor-rotation="270"]),.annotationEditorLayer[data-main-rotation="90"] :is([data-editor-rotation="0"],[data-editor-rotation="180"]),.annotationEditorLayer[data-main-rotation="180"] :is([data-editor-rotation="270"],[data-editor-rotation="90"]),.annotationEditorLayer[data-main-rotation="270"] :is([data-editor-rotation="180"],[data-editor-rotation="0"])) > .resizers > .resizer),.middleLeft:is(:is(.annotationEditorLayer[data-main-rotation="0"] :is([data-editor-rotation="90"],[data-editor-rotation="270"]),.annotationEditorLayer[data-main-rotation="90"] :is([data-editor-rotation="0"],[data-editor-rotation="180"]),.annotationEditorLayer[data-main-rotation="180"] :is([data-editor-rotation="270"],[data-editor-rotation="90"]),.annotationEditorLayer[data-main-rotation="270"] :is([data-editor-rotation="180"],[data-editor-rotation="0"])) > .resizers > .resizer){ - cursor:ns-resize; - } - -:is(.annotationEditorLayer :is([data-main-rotation="0"] [data-editor-rotation="90"],[data-main-rotation="90"] [data-editor-rotation="0"],[data-main-rotation="180"] [data-editor-rotation="270"],[data-main-rotation="270"] [data-editor-rotation="180"])) .editToolbar{ - rotate:270deg; - } - -[dir="ltr"] :is(:is(.annotationEditorLayer :is([data-main-rotation="0"] [data-editor-rotation="90"],[data-main-rotation="90"] [data-editor-rotation="0"],[data-main-rotation="180"] [data-editor-rotation="270"],[data-main-rotation="270"] [data-editor-rotation="180"])) .editToolbar){ - inset-inline-end:calc(0px - var(--editor-toolbar-vert-offset)); - inset-block-start:0; - } - -[dir="rtl"] :is(:is(.annotationEditorLayer :is([data-main-rotation="0"] [data-editor-rotation="90"],[data-main-rotation="90"] [data-editor-rotation="0"],[data-main-rotation="180"] [data-editor-rotation="270"],[data-main-rotation="270"] [data-editor-rotation="180"])) .editToolbar){ - inset-inline-end:calc(100% + var(--editor-toolbar-vert-offset)); - inset-block-start:0; - } - -:is(.annotationEditorLayer :is([data-main-rotation="0"] [data-editor-rotation="180"],[data-main-rotation="90"] [data-editor-rotation="90"],[data-main-rotation="180"] [data-editor-rotation="0"],[data-main-rotation="270"] [data-editor-rotation="270"])) .editToolbar{ - rotate:180deg; - inset-inline-end:100%; - inset-block-start:calc(0px - var(--editor-toolbar-vert-offset)); - } - -:is(.annotationEditorLayer :is([data-main-rotation="0"] [data-editor-rotation="270"],[data-main-rotation="90"] [data-editor-rotation="180"],[data-main-rotation="180"] [data-editor-rotation="90"],[data-main-rotation="270"] [data-editor-rotation="0"])) .editToolbar{ - rotate:90deg; - } - -[dir="ltr"] :is(:is(.annotationEditorLayer :is([data-main-rotation="0"] [data-editor-rotation="270"],[data-main-rotation="90"] [data-editor-rotation="180"],[data-main-rotation="180"] [data-editor-rotation="90"],[data-main-rotation="270"] [data-editor-rotation="0"])) .editToolbar){ - inset-inline-end:calc(100% + var(--editor-toolbar-vert-offset)); - inset-block-start:100%; - } - -[dir="rtl"] :is(:is(.annotationEditorLayer :is([data-main-rotation="0"] [data-editor-rotation="270"],[data-main-rotation="90"] [data-editor-rotation="180"],[data-main-rotation="180"] [data-editor-rotation="90"],[data-main-rotation="270"] [data-editor-rotation="0"])) .editToolbar){ - inset-inline-start:calc(0px - var(--editor-toolbar-vert-offset)); - inset-block-start:0; - } - -.dialog.altText::backdrop{ - -webkit-mask:url(#alttext-manager-mask); - mask:url(#alttext-manager-mask); - } - -.dialog.altText.positioned{ - margin:0; - } - -.dialog.altText #altTextContainer{ - width:300px; - height:-moz-fit-content; - height:fit-content; - display:inline-flex; - flex-direction:column; - align-items:flex-start; - gap:16px; - } - -:is(.dialog.altText #altTextContainer) #overallDescription{ - display:flex; - flex-direction:column; - align-items:flex-start; - gap:4px; - align-self:stretch; - } - -:is(:is(.dialog.altText #altTextContainer) #overallDescription) span{ - align-self:stretch; - } - -:is(:is(.dialog.altText #altTextContainer) #overallDescription) .title{ - font-size:13px; - font-style:normal; - font-weight:590; - } - -:is(.dialog.altText #altTextContainer) #addDescription{ - display:flex; - flex-direction:column; - align-items:stretch; - gap:8px; - } - -:is(:is(.dialog.altText #altTextContainer) #addDescription) .descriptionArea{ - flex:1; - padding-inline:24px 10px; - } - -:is(:is(:is(.dialog.altText #altTextContainer) #addDescription) .descriptionArea) textarea{ - width:100%; - min-height:75px; - } - -:is(.dialog.altText #altTextContainer) #buttons{ - display:flex; - justify-content:flex-end; - align-items:flex-start; - gap:8px; - align-self:stretch; - } - -.dialog.newAltText{ - --new-alt-text-ai-disclaimer-icon:url(images/altText_disclaimer.svg); - --new-alt-text-spinner-icon:url(images/altText_spinner.svg); - --csstools-light-dark-toggle--117:var(--csstools-color-scheme--light) #2b2a33; - --preview-image-bg-color:var(--csstools-light-dark-toggle--117, #f0f0f4); - --preview-image-border:none; -} - -@supports (color: light-dark(red, red)){ -.dialog.newAltText{ - --preview-image-bg-color:light-dark(#f0f0f4, #2b2a33); -} -} - -@supports not (color: light-dark(tan, tan)){ - -.dialog.newAltText *{ - --csstools-light-dark-toggle--117:var(--csstools-color-scheme--light) #2b2a33; - --preview-image-bg-color:var(--csstools-light-dark-toggle--117, #f0f0f4); - } -} - -@media screen and (forced-colors: active){ - -.dialog.newAltText{ - --preview-image-bg-color:ButtonFace; - --preview-image-border:1px solid ButtonText; -} - } - -.dialog.newAltText{ - - width:80%; - max-width:570px; - min-width:300px; - padding:0; -} - -.dialog.newAltText.noAi #newAltTextDisclaimer,.dialog.newAltText.noAi #newAltTextCreateAutomatically{ - display:none !important; - } - -.dialog.newAltText.aiInstalling #newAltTextCreateAutomatically{ - display:none !important; - } - -.dialog.newAltText.aiInstalling #newAltTextDownloadModel{ - display:flex !important; - } - -.dialog.newAltText.error #newAltTextNotNow{ - display:none !important; - } - -.dialog.newAltText.error #newAltTextCancel{ - display:inline-block !important; - } - -.dialog.newAltText:not(.error) #newAltTextError{ - display:none !important; - } - -.dialog.newAltText #newAltTextContainer{ - display:flex; - width:auto; - padding:16px; - flex-direction:column; - justify-content:flex-end; - align-items:flex-start; - gap:12px; - flex:0 1 auto; - line-height:normal; - } - -:is(.dialog.newAltText #newAltTextContainer) #mainContent{ - display:flex; - justify-content:flex-end; - align-items:flex-start; - gap:12px; - align-self:stretch; - flex:1 1 auto; - } - -:is(:is(.dialog.newAltText #newAltTextContainer) #mainContent) #descriptionAndSettings{ - display:flex; - flex-direction:column; - align-items:flex-start; - gap:16px; - flex:1 0 0; - align-self:stretch; - } - -:is(:is(.dialog.newAltText #newAltTextContainer) #mainContent) #descriptionInstruction{ - display:flex; - flex-direction:column; - align-items:flex-start; - gap:8px; - align-self:stretch; - flex:1 1 auto; - } - -:is(:is(:is(.dialog.newAltText #newAltTextContainer) #mainContent) #descriptionInstruction) #newAltTextDescriptionContainer{ - width:100%; - height:70px; - position:relative; - } - -:is(:is(:is(:is(.dialog.newAltText #newAltTextContainer) #mainContent) #descriptionInstruction) #newAltTextDescriptionContainer) textarea{ - width:100%; - height:100%; - padding:8px; - } - -:is(:is(:is(:is(:is(.dialog.newAltText #newAltTextContainer) #mainContent) #descriptionInstruction) #newAltTextDescriptionContainer) textarea)::-moz-placeholder{ - color:var(--text-secondary-color); - } - -:is(:is(:is(:is(:is(.dialog.newAltText #newAltTextContainer) #mainContent) #descriptionInstruction) #newAltTextDescriptionContainer) textarea)::placeholder{ - color:var(--text-secondary-color); - } - -:is(:is(:is(:is(.dialog.newAltText #newAltTextContainer) #mainContent) #descriptionInstruction) #newAltTextDescriptionContainer) .altTextSpinner{ - display:none; - position:absolute; - width:16px; - height:16px; - inset-inline-start:8px; - inset-block-start:8px; - -webkit-mask-size:cover; - mask-size:cover; - background-color:var(--text-secondary-color); - pointer-events:none; - } - -.loading:is(:is(:is(:is(.dialog.newAltText #newAltTextContainer) #mainContent) #descriptionInstruction) #newAltTextDescriptionContainer) textarea::-moz-placeholder{ - color:transparent; - } - -.loading:is(:is(:is(:is(.dialog.newAltText #newAltTextContainer) #mainContent) #descriptionInstruction) #newAltTextDescriptionContainer) textarea::placeholder{ - color:transparent; - } - -.loading:is(:is(:is(:is(.dialog.newAltText #newAltTextContainer) #mainContent) #descriptionInstruction) #newAltTextDescriptionContainer) .altTextSpinner{ - display:inline-block; - -webkit-mask-image:var(--new-alt-text-spinner-icon); - mask-image:var(--new-alt-text-spinner-icon); - } - -:is(:is(:is(.dialog.newAltText #newAltTextContainer) #mainContent) #descriptionInstruction) #newAltTextDescription{ - font-size:11px; - } - -:is(:is(:is(.dialog.newAltText #newAltTextContainer) #mainContent) #descriptionInstruction) #newAltTextDisclaimer{ - display:flex; - flex-direction:row; - align-items:flex-start; - gap:4px; - font-size:11px; - } - -:is(:is(:is(:is(.dialog.newAltText #newAltTextContainer) #mainContent) #descriptionInstruction) #newAltTextDisclaimer)::before{ - content:""; - display:inline-block; - width:17px; - height:16px; - -webkit-mask-image:var(--new-alt-text-ai-disclaimer-icon); - mask-image:var(--new-alt-text-ai-disclaimer-icon); - -webkit-mask-size:cover; - mask-size:cover; - background-color:var(--text-secondary-color); - flex:1 0 auto; - } - -:is(:is(.dialog.newAltText #newAltTextContainer) #mainContent) #newAltTextDownloadModel{ - display:flex; - align-items:center; - gap:4px; - align-self:stretch; - } - -:is(:is(:is(.dialog.newAltText #newAltTextContainer) #mainContent) #newAltTextDownloadModel)::before{ - content:""; - display:inline-block; - width:16px; - height:16px; - -webkit-mask-image:var(--new-alt-text-spinner-icon); - mask-image:var(--new-alt-text-spinner-icon); - -webkit-mask-size:cover; - mask-size:cover; - background-color:var(--text-secondary-color); - } - -:is(:is(.dialog.newAltText #newAltTextContainer) #mainContent) #newAltTextImagePreview{ - width:180px; - aspect-ratio:1; - display:flex; - justify-content:center; - align-items:center; - flex:0 0 auto; - background-color:var(--preview-image-bg-color); - border:var(--preview-image-border); - } - -:is(:is(:is(.dialog.newAltText #newAltTextContainer) #mainContent) #newAltTextImagePreview) > canvas{ - max-width:100%; - max-height:100%; - } - -.colorPicker{ - --csstools-light-dark-toggle--118:var(--csstools-color-scheme--light) #80ebff; - --hover-outline-color:var(--csstools-light-dark-toggle--118, #0250bb); - --csstools-light-dark-toggle--119:var(--csstools-color-scheme--light) #aaf2ff; - --selected-outline-color:var(--csstools-light-dark-toggle--119, #0060df); - --csstools-light-dark-toggle--120:var(--csstools-color-scheme--light) #52525e; - --swatch-border-color:var(--csstools-light-dark-toggle--120, #cfcfd8); -} - -@supports (color: light-dark(red, red)){ -.colorPicker{ - --hover-outline-color:light-dark(#0250bb, #80ebff); - --selected-outline-color:light-dark(#0060df, #aaf2ff); - --swatch-border-color:light-dark(#cfcfd8, #52525e); -} -} - -@supports not (color: light-dark(tan, tan)){ - -.colorPicker *{ - --csstools-light-dark-toggle--118:var(--csstools-color-scheme--light) #80ebff; - --hover-outline-color:var(--csstools-light-dark-toggle--118, #0250bb); - --csstools-light-dark-toggle--119:var(--csstools-color-scheme--light) #aaf2ff; - --selected-outline-color:var(--csstools-light-dark-toggle--119, #0060df); - --csstools-light-dark-toggle--120:var(--csstools-color-scheme--light) #52525e; - --swatch-border-color:var(--csstools-light-dark-toggle--120, #cfcfd8); - } -} - -@media screen and (forced-colors: active){ - -.colorPicker{ - --hover-outline-color:Highlight; - --selected-outline-color:var(--hover-outline-color); - --swatch-border-color:ButtonText; -} - } - -.colorPicker .swatch{ - width:16px; - height:16px; - border:1px solid var(--swatch-border-color); - border-radius:100%; - outline-offset:2px; - box-sizing:border-box; - forced-color-adjust:none; - } - -.colorPicker button:is(:hover,.selected) > .swatch{ - border:none; - } - -.basicColorPicker{ - width:28px; -} - -.basicColorPicker::-moz-color-swatch{ - border-radius:100%; - } - -.basicColorPicker::-webkit-color-swatch{ - border-radius:100%; - } - -.annotationEditorLayer[data-main-rotation="0"] .highlightEditor:not(.free) > .editToolbar{ - rotate:0deg; - } - -.annotationEditorLayer[data-main-rotation="90"] .highlightEditor:not(.free) > .editToolbar{ - rotate:270deg; - } - -.annotationEditorLayer[data-main-rotation="180"] .highlightEditor:not(.free) > .editToolbar{ - rotate:180deg; - } - -.annotationEditorLayer[data-main-rotation="270"] .highlightEditor:not(.free) > .editToolbar{ - rotate:90deg; - } - -.annotationEditorLayer .highlightEditor{ - position:absolute; - background:transparent; - z-index:1; - cursor:auto; - max-width:100%; - max-height:100%; - border:none; - outline:none; - pointer-events:none; - transform-origin:0 0; - } - -:is(.annotationEditorLayer .highlightEditor):not(.free){ - transform:none; - } - -:is(.annotationEditorLayer .highlightEditor) .internal{ - position:absolute; - top:0; - left:0; - width:100%; - height:100%; - pointer-events:auto; - } - -.disabled:is(.annotationEditorLayer .highlightEditor) .internal{ - pointer-events:none; - } - -.selectedEditor:is(.annotationEditorLayer .highlightEditor) .internal{ - cursor:pointer; - } - -:is(.annotationEditorLayer .highlightEditor) .editToolbar{ - --editor-toolbar-colorpicker-arrow-image:url(images/toolbarButton-menuArrow.svg); - - transform-origin:center !important; - } - -:is(:is(:is(.annotationEditorLayer .highlightEditor) .editToolbar) .buttons) .colorPicker{ - position:relative; - width:auto; - display:flex; - justify-content:center; - align-items:center; - gap:4px; - padding:4px; - } - -:is(:is(:is(:is(.annotationEditorLayer .highlightEditor) .editToolbar) .buttons) .colorPicker)::after{ - content:""; - -webkit-mask-image:var(--editor-toolbar-colorpicker-arrow-image); - mask-image:var(--editor-toolbar-colorpicker-arrow-image); - -webkit-mask-repeat:no-repeat; - mask-repeat:no-repeat; - -webkit-mask-position:center; - mask-position:center; - display:inline-block; - background-color:var(--editor-toolbar-fg-color); - width:12px; - height:12px; - } - -:is(:is(:is(:is(.annotationEditorLayer .highlightEditor) .editToolbar) .buttons) .colorPicker):hover::after{ - background-color:var(--editor-toolbar-hover-fg-color); - } - -:is(:is(:is(:is(.annotationEditorLayer .highlightEditor) .editToolbar) .buttons) .colorPicker):has(.dropdown:not(.hidden)){ - background-color:var(--editor-toolbar-hover-bg-color); - } - -:is(:is(:is(:is(.annotationEditorLayer .highlightEditor) .editToolbar) .buttons) .colorPicker):has(.dropdown:not(.hidden))::after{ - scale:-1; - } - -:is(:is(:is(:is(.annotationEditorLayer .highlightEditor) .editToolbar) .buttons) .colorPicker) .dropdown{ - position:absolute; - display:flex; - justify-content:center; - align-items:center; - flex-direction:column; - gap:11px; - padding-block:8px; - border-radius:6px; - background-color:var(--editor-toolbar-bg-color); - border:1px solid var(--editor-toolbar-border-color); - box-shadow:var(--editor-toolbar-shadow); - inset-block-start:calc(100% + 4px); - width:calc(100% + 2 * var(--editor-toolbar-padding)); - } - -:is(:is(:is(:is(:is(.annotationEditorLayer .highlightEditor) .editToolbar) .buttons) .colorPicker) .dropdown) button{ - width:100%; - height:auto; - border:none; - cursor:pointer; - display:flex; - justify-content:center; - align-items:center; - background:none; - } - -:is(:is(:is(:is(:is(:is(.annotationEditorLayer .highlightEditor) .editToolbar) .buttons) .colorPicker) .dropdown) button):is(:active,:focus-visible){ - outline:none; - } - -:is(:is(:is(:is(:is(:is(.annotationEditorLayer .highlightEditor) .editToolbar) .buttons) .colorPicker) .dropdown) button) > .swatch{ - outline-offset:2px; - } - -[aria-selected="true"]:is(:is(:is(:is(:is(:is(.annotationEditorLayer .highlightEditor) .editToolbar) .buttons) .colorPicker) .dropdown) button) > .swatch{ - outline:2px solid var(--selected-outline-color); - } - -:is(:is(:is(:is(:is(:is(.annotationEditorLayer .highlightEditor) .editToolbar) .buttons) .colorPicker) .dropdown) button):is(:hover,:active,:focus-visible) > .swatch{ - outline:2px solid var(--hover-outline-color); - } - -.editorParamsToolbar:has(#highlightParamsToolbarContainer){ - padding:unset; -} - -#highlightParamsToolbarContainer{ - gap:16px; - padding-inline:10px; - padding-block-end:12px; -} - -#highlightParamsToolbarContainer .colorPicker{ - display:flex; - flex-direction:column; - gap:8px; - } - -:is(#highlightParamsToolbarContainer .colorPicker) .dropdown{ - display:flex; - justify-content:space-between; - align-items:center; - flex-direction:row; - height:auto; - } - -:is(:is(#highlightParamsToolbarContainer .colorPicker) .dropdown) button{ - width:auto; - height:auto; - border:none; - cursor:pointer; - display:flex; - justify-content:center; - align-items:center; - background:none; - flex:0 0 auto; - padding:0; - } - -:is(:is(:is(#highlightParamsToolbarContainer .colorPicker) .dropdown) button) .swatch{ - width:24px; - height:24px; - } - -:is(:is(:is(#highlightParamsToolbarContainer .colorPicker) .dropdown) button):is(:active,:focus-visible){ - outline:none; - } - -[aria-selected="true"]:is(:is(:is(#highlightParamsToolbarContainer .colorPicker) .dropdown) button) > .swatch{ - outline:2px solid var(--selected-outline-color); - } - -:is(:is(:is(#highlightParamsToolbarContainer .colorPicker) .dropdown) button):is(:hover,:active,:focus-visible) > .swatch{ - outline:2px solid var(--hover-outline-color); - } - -#highlightParamsToolbarContainer #editorHighlightThickness{ - display:flex; - flex-direction:column; - align-items:center; - gap:4px; - align-self:stretch; - } - -:is(#highlightParamsToolbarContainer #editorHighlightThickness) .editorParamsLabel{ - height:auto; - align-self:stretch; - } - -:is(#highlightParamsToolbarContainer #editorHighlightThickness) .thicknessPicker{ - display:flex; - justify-content:space-between; - align-items:center; - align-self:stretch; - - --csstools-light-dark-toggle--121:var(--csstools-color-scheme--light) #80808e; - - --example-color:var(--csstools-light-dark-toggle--121, #bfbfc9); - } - -@supports (color: light-dark(red, red)){ -:is(#highlightParamsToolbarContainer #editorHighlightThickness) .thicknessPicker{ - - --example-color:light-dark(#bfbfc9, #80808e); - } -} - -@supports not (color: light-dark(tan, tan)){ - -:is(:is(#highlightParamsToolbarContainer #editorHighlightThickness) .thicknessPicker) *{ - - --csstools-light-dark-toggle--121:var(--csstools-color-scheme--light) #80808e; - - --example-color:var(--csstools-light-dark-toggle--121, #bfbfc9); - } -} - -@media screen and (forced-colors: active){ - -:is(#highlightParamsToolbarContainer #editorHighlightThickness) .thicknessPicker{ - --example-color:CanvasText; - } - } - -:is(:is(:is(#highlightParamsToolbarContainer #editorHighlightThickness) .thicknessPicker) > .editorParamsSlider[disabled]){ - opacity:0.4; - } - -:is(:is(#highlightParamsToolbarContainer #editorHighlightThickness) .thicknessPicker)::before,:is(:is(#highlightParamsToolbarContainer #editorHighlightThickness) .thicknessPicker)::after{ - content:""; - width:8px; - aspect-ratio:1; - display:block; - border-radius:100%; - background-color:var(--example-color); - } - -:is(:is(#highlightParamsToolbarContainer #editorHighlightThickness) .thicknessPicker)::after{ - width:24px; - } - -:is(:is(#highlightParamsToolbarContainer #editorHighlightThickness) .thicknessPicker) .editorParamsSlider{ - width:unset; - height:14px; - } - -#highlightParamsToolbarContainer #editorHighlightVisibility{ - display:flex; - flex-direction:column; - align-items:flex-start; - gap:8px; - align-self:stretch; - } - -:is(#highlightParamsToolbarContainer #editorHighlightVisibility) .divider{ - --csstools-light-dark-toggle--122:var(--csstools-color-scheme--light) #8f8f9d; - --divider-color:var(--csstools-light-dark-toggle--122, #d7d7db); - } - -@supports (color: light-dark(red, red)){ -:is(#highlightParamsToolbarContainer #editorHighlightVisibility) .divider{ - --divider-color:light-dark(#d7d7db, #8f8f9d); - } -} - -@supports not (color: light-dark(tan, tan)){ - -:is(:is(#highlightParamsToolbarContainer #editorHighlightVisibility) .divider) *{ - --csstools-light-dark-toggle--122:var(--csstools-color-scheme--light) #8f8f9d; - --divider-color:var(--csstools-light-dark-toggle--122, #d7d7db); - } -} - -@media screen and (forced-colors: active){ - -:is(#highlightParamsToolbarContainer #editorHighlightVisibility) .divider{ - --divider-color:CanvasText; - } - } - -:is(#highlightParamsToolbarContainer #editorHighlightVisibility) .divider{ - - margin-block:4px; - width:100%; - height:1px; - background-color:var(--divider-color); - } - -:is(#highlightParamsToolbarContainer #editorHighlightVisibility) .toggler{ - display:flex; - justify-content:space-between; - align-items:center; - align-self:stretch; - } - -#altTextSettingsDialog{ - padding:16px; -} - -#altTextSettingsDialog #altTextSettingsContainer{ - display:flex; - width:573px; - flex-direction:column; - gap:16px; - } - -:is(#altTextSettingsDialog #altTextSettingsContainer) .mainContainer{ - gap:16px; - } - -:is(#altTextSettingsDialog #altTextSettingsContainer) .description{ - color:var(--text-secondary-color); - } - -:is(#altTextSettingsDialog #altTextSettingsContainer) #aiModelSettings{ - display:flex; - flex-direction:column; - gap:12px; - } - -:is(:is(#altTextSettingsDialog #altTextSettingsContainer) #aiModelSettings) button{ - width:-moz-fit-content; - width:fit-content; - } - -.download:is(:is(#altTextSettingsDialog #altTextSettingsContainer) #aiModelSettings) #deleteModelButton{ - display:none; - } - -:is(:is(#altTextSettingsDialog #altTextSettingsContainer) #aiModelSettings):not(.download) #downloadModelButton{ - display:none; - } - -:is(#altTextSettingsDialog #altTextSettingsContainer) #automaticAltText,:is(#altTextSettingsDialog #altTextSettingsContainer) #altTextEditor{ - display:flex; - flex-direction:column; - gap:8px; - } - -:is(#altTextSettingsDialog #altTextSettingsContainer) #createModelDescription,:is(#altTextSettingsDialog #altTextSettingsContainer) #aiModelSettings,:is(#altTextSettingsDialog #altTextSettingsContainer) #showAltTextDialogDescription{ - padding-inline-start:40px; - } - -:is(#altTextSettingsDialog #altTextSettingsContainer) #automaticSettings{ - display:flex; - flex-direction:column; - gap:16px; - } - -.sidebar{ - --csstools-light-dark-toggle--123:var(--csstools-color-scheme--light) #23222b; - --sidebar-bg-color:var(--csstools-light-dark-toggle--123, #fff); - --csstools-light-dark-toggle--124:var(--csstools-color-scheme--light) rgb(251 251 254 / 0.1); - --sidebar-border-color:var(--csstools-light-dark-toggle--124, rgb(21 20 26 / 0.1)); - --csstools-light-dark-toggle--125:var(--csstools-color-scheme--light) rgb(0 0 0 / 0.2); - --csstools-light-dark-toggle--126:var(--csstools-color-scheme--light) rgb(0 0 0 / 0.4); - --sidebar-box-shadow:0 0.25px 0.75px var(--csstools-light-dark-toggle--125, rgb(0 0 0 / 0.05)), 0 2px 6px 0 var(--csstools-light-dark-toggle--126, rgb(0 0 0 / 0.1)); - --sidebar-border-radius:8px; - --sidebar-padding:5px; - --sidebar-min-width:180px; - --sidebar-max-width:632px; - --sidebar-width:239px; - --resizer-width:4px; - --csstools-light-dark-toggle--127:var(--csstools-color-scheme--light) #00cadb; - --resizer-hover-bg-color:var(--csstools-light-dark-toggle--127, #0062fa); -} - -@supports (color: light-dark(red, red)){ -.sidebar{ - --sidebar-bg-color:light-dark(#fff, #23222b); -} -} - -@supports (color: light-dark(red, red)) and (color: rgb(0 0 0 / 0)){ -.sidebar{ - --sidebar-border-color:light-dark( - rgb(21 20 26 / 0.1), - rgb(251 251 254 / 0.1) +:is( + .annotationEditorLayer + :is( + .freeTextEditor, + .inkEditor, + .stampEditor, + .highlightEditor, + .signatureEditor + ), + .textLayer + ) + .editToolbar { + --editor-toolbar-delete-image: url(images/editor-toolbar-delete.svg); + --csstools-light-dark-toggle--105: var(--csstools-color-scheme--light) #2b2a33; + --editor-toolbar-bg-color: var(--csstools-light-dark-toggle--105, #f0f0f4); + --editor-toolbar-highlight-image: url(images/toolbarButton-editorHighlight.svg); + --csstools-light-dark-toggle--106: var(--csstools-color-scheme--light) #fbfbfe; + --editor-toolbar-fg-color: var(--csstools-light-dark-toggle--106, #2e2e56); + --editor-toolbar-border-color: #8f8f9d; + --editor-toolbar-hover-border-color: var(--editor-toolbar-border-color); + --csstools-light-dark-toggle--107: var(--csstools-color-scheme--light) #52525e; + --editor-toolbar-hover-bg-color: var( + --csstools-light-dark-toggle--107, + #e0e0e6 ); - --sidebar-box-shadow:0 0.25px 0.75px light-dark(rgb(0 0 0 / 0.05), rgb(0 0 0 / 0.2)), 0 2px 6px 0 light-dark(rgb(0 0 0 / 0.1), rgb(0 0 0 / 0.4)); -} + --editor-toolbar-hover-fg-color: var(--editor-toolbar-fg-color); + --editor-toolbar-hover-outline: none; + --csstools-light-dark-toggle--108: var(--csstools-color-scheme--light) #0df; + --editor-toolbar-focus-outline-color: var( + --csstools-light-dark-toggle--108, + #0060df + ); + --editor-toolbar-shadow: 0 2px 6px 0 rgb(58 57 68 / 0.2); + --editor-toolbar-height: 28px; + --editor-toolbar-padding: 2px; + --csstools-light-dark-toggle--109: var(--csstools-color-scheme--light) #54ffbd; + --alt-text-done-color: var(--csstools-light-dark-toggle--109, #2ac3a2); + --csstools-light-dark-toggle--110: var(--csstools-color-scheme--light) #80ebff; + --alt-text-warning-color: var(--csstools-light-dark-toggle--110, #0090ed); + --alt-text-hover-done-color: var(--alt-text-done-color); + --alt-text-hover-warning-color: var(--alt-text-warning-color); } -@supports (color: light-dark(red, red)){ -.sidebar{ - --resizer-hover-bg-color:light-dark(#0062fa, #00cadb); -} -} - -@supports not (color: light-dark(tan, tan)){ - -.sidebar *{ - --csstools-light-dark-toggle--123:var(--csstools-color-scheme--light) #23222b; - --sidebar-bg-color:var(--csstools-light-dark-toggle--123, #fff); - --csstools-light-dark-toggle--124:var(--csstools-color-scheme--light) rgb(251 251 254 / 0.1); - --sidebar-border-color:var(--csstools-light-dark-toggle--124, rgb(21 20 26 / 0.1)); - --csstools-light-dark-toggle--125:var(--csstools-color-scheme--light) rgb(0 0 0 / 0.2); - --csstools-light-dark-toggle--126:var(--csstools-color-scheme--light) rgb(0 0 0 / 0.4); - --sidebar-box-shadow:0 0.25px 0.75px var(--csstools-light-dark-toggle--125, rgb(0 0 0 / 0.05)), 0 2px 6px 0 var(--csstools-light-dark-toggle--126, rgb(0 0 0 / 0.1)); - --csstools-light-dark-toggle--127:var(--csstools-color-scheme--light) #00cadb; - --resizer-hover-bg-color:var(--csstools-light-dark-toggle--127, #0062fa); +@supports (color: light-dark(red, red)) { + :is( + .annotationEditorLayer + :is( + .freeTextEditor, + .inkEditor, + .stampEditor, + .highlightEditor, + .signatureEditor + ), + .textLayer + ) + .editToolbar { + --editor-toolbar-bg-color: light-dark(#f0f0f4, #2b2a33); + --editor-toolbar-fg-color: light-dark(#2e2e56, #fbfbfe); + --editor-toolbar-hover-bg-color: light-dark(#e0e0e6, #52525e); + --editor-toolbar-focus-outline-color: light-dark(#0060df, #0df); + --alt-text-done-color: light-dark(#2ac3a2, #54ffbd); + --alt-text-warning-color: light-dark(#0090ed, #80ebff); } } -@media screen and (forced-colors: active){ - -.sidebar{ - --sidebar-bg-color:Canvas; - --sidebar-border-color:CanvasText; - --sidebar-box-shadow:none; - --resizer-hover-bg-color:CanvasText; -} - } - -.sidebar{ - - border-radius:var(--sidebar-border-radius); - box-shadow:var(--sidebar-box-shadow); - border:1px solid var(--sidebar-border-color); - background-color:var(--sidebar-bg-color); - inset-block-start:calc(100% + var(--doorhanger-height) - 2px); - padding-block:var(--sidebar-padding); - width:var(--sidebar-width); - min-width:var(--sidebar-min-width); - max-width:var(--sidebar-max-width); -} - -.sidebar .sidebarResizer{ - width:var(--resizer-width); - background-color:transparent; - forced-color-adjust:none; - cursor:ew-resize; - position:absolute; - inset-block:calc(var(--sidebar-padding) + var(--sidebar-border-radius)); - inset-inline-start:calc(0px - var(--resizer-width) / 2); - transition:background-color 0.5s ease-in-out; - box-sizing:border-box; - border:1px solid transparent; - border-block-width:0; - background-clip:content-box; - } - -:is(.sidebar .sidebarResizer):hover{ - background-color:var(--resizer-hover-bg-color); - } - -.sidebar.resizing{ - cursor:ew-resize; - -webkit-user-select:none; - -moz-user-select:none; - user-select:none; - } - -.sidebar.resizing :not(.sidebarResizer){ - pointer-events:none; - } - -:root{ - --csstools-color-scheme--light:initial; - color-scheme:light dark; - - --viewer-container-height:0; - --pdfViewer-padding-bottom:0; - --page-margin:1px auto -8px; - --page-border:9px solid transparent; - --spreadHorizontalWrapped-margin-LR:-3.5px; - --loading-icon-delay:400ms; - --csstools-light-dark-toggle--128:var(--csstools-color-scheme--light) #0df; - --focus-ring-color:var(--csstools-light-dark-toggle--128, #0060df); - --focus-ring-outline:2px solid var(--focus-ring-color); -} - -@supports (color: light-dark(red, red)){ -:root{ - --focus-ring-color:light-dark(#0060df, #0df); -} -} - -@supports not (color: light-dark(tan, tan)){ - -:root *{ - --csstools-light-dark-toggle--128:var(--csstools-color-scheme--light) #0df; - --focus-ring-color:var(--csstools-light-dark-toggle--128, #0060df); +@supports not (color: light-dark(tan, tan)) { + :is( + :is( + .annotationEditorLayer + :is( + .freeTextEditor, + .inkEditor, + .stampEditor, + .highlightEditor, + .signatureEditor + ), + .textLayer + ) + .editToolbar + ) + * { + --csstools-light-dark-toggle--105: var(--csstools-color-scheme--light) + #2b2a33; + --editor-toolbar-bg-color: var(--csstools-light-dark-toggle--105, #f0f0f4); + --csstools-light-dark-toggle--106: var(--csstools-color-scheme--light) + #fbfbfe; + --editor-toolbar-fg-color: var(--csstools-light-dark-toggle--106, #2e2e56); + --csstools-light-dark-toggle--107: var(--csstools-color-scheme--light) + #52525e; + --editor-toolbar-hover-bg-color: var( + --csstools-light-dark-toggle--107, + #e0e0e6 + ); + --csstools-light-dark-toggle--108: var(--csstools-color-scheme--light) #0df; + --editor-toolbar-focus-outline-color: var( + --csstools-light-dark-toggle--108, + #0060df + ); + --csstools-light-dark-toggle--109: var(--csstools-color-scheme--light) + #54ffbd; + --alt-text-done-color: var(--csstools-light-dark-toggle--109, #2ac3a2); + --csstools-light-dark-toggle--110: var(--csstools-color-scheme--light) + #80ebff; + --alt-text-warning-color: var(--csstools-light-dark-toggle--110, #0090ed); } } -@media (prefers-color-scheme: dark){ - -:root{ - --csstools-color-scheme--light: light; -} -} - -@media screen and (forced-colors: active){ - -:root{ - --pdfViewer-padding-bottom:9px; - --page-margin:8px auto -1px; - --page-border:1px solid CanvasText; - --spreadHorizontalWrapped-margin-LR:3.5px; - --focus-ring-color:CanvasText; -} +@media screen and (forced-colors: active) { + :is( + .annotationEditorLayer + :is( + .freeTextEditor, + .inkEditor, + .stampEditor, + .highlightEditor, + .signatureEditor + ), + .textLayer + ) + .editToolbar { + --editor-toolbar-bg-color: ButtonFace; + --editor-toolbar-fg-color: ButtonText; + --editor-toolbar-border-color: ButtonText; + --editor-toolbar-hover-border-color: AccentColor; + --editor-toolbar-hover-bg-color: ButtonFace; + --editor-toolbar-hover-fg-color: AccentColor; + --editor-toolbar-hover-outline: 2px solid + var(--editor-toolbar-hover-border-color); + --editor-toolbar-focus-outline-color: ButtonBorder; + --editor-toolbar-shadow: none; + --alt-text-done-color: var(--editor-toolbar-fg-color); + --alt-text-warning-color: var(--editor-toolbar-fg-color); + --alt-text-hover-done-color: var(--editor-toolbar-hover-fg-color); + --alt-text-hover-warning-color: var(--editor-toolbar-hover-fg-color); } +} -[data-main-rotation="90"]{ - transform:rotate(90deg) translateY(-100%); +:is( + .annotationEditorLayer + :is( + .freeTextEditor, + .inkEditor, + .stampEditor, + .highlightEditor, + .signatureEditor + ), + .textLayer + ) + .editToolbar { + display: flex; + width: -moz-fit-content; + width: fit-content; + height: var(--editor-toolbar-height); + flex-direction: column; + justify-content: center; + align-items: center; + cursor: default; + pointer-events: auto; + box-sizing: content-box; + padding: var(--editor-toolbar-padding); + + position: absolute; + inset-inline-end: 0; + inset-block-start: calc(100% + var(--editor-toolbar-vert-offset)); + + border-radius: 6px; + background-color: var(--editor-toolbar-bg-color); + border: 1px solid var(--editor-toolbar-border-color); + box-shadow: var(--editor-toolbar-shadow); } -[data-main-rotation="180"]{ - transform:rotate(180deg) translate(-100%, -100%); + +.hidden:is( + :is( + .annotationEditorLayer + :is( + .freeTextEditor, + .inkEditor, + .stampEditor, + .highlightEditor, + .signatureEditor + ), + .textLayer + ) + .editToolbar +) { + display: none; } -[data-main-rotation="270"]{ - transform:rotate(270deg) translateX(-100%); + +:is( + :is( + .annotationEditorLayer + :is( + .freeTextEditor, + .inkEditor, + .stampEditor, + .highlightEditor, + .signatureEditor + ), + .textLayer + ) + .editToolbar +):has(:focus-visible) { + border-color: transparent; +} + +[dir='ltr'] + :is( + :is( + .annotationEditorLayer + :is( + .freeTextEditor, + .inkEditor, + .stampEditor, + .highlightEditor, + .signatureEditor + ), + .textLayer + ) + .editToolbar + ) { + transform-origin: 100% 0; +} + +[dir='rtl'] + :is( + :is( + .annotationEditorLayer + :is( + .freeTextEditor, + .inkEditor, + .stampEditor, + .highlightEditor, + .signatureEditor + ), + .textLayer + ) + .editToolbar + ) { + transform-origin: 0 0; +} + +:is( + :is( + .annotationEditorLayer + :is( + .freeTextEditor, + .inkEditor, + .stampEditor, + .highlightEditor, + .signatureEditor + ), + .textLayer + ) + .editToolbar + ) + .buttons { + display: flex; + justify-content: center; + align-items: center; + gap: 0; + height: 100%; +} + +:is( + :is( + :is( + .annotationEditorLayer + :is( + .freeTextEditor, + .inkEditor, + .stampEditor, + .highlightEditor, + .signatureEditor + ), + .textLayer + ) + .editToolbar + ) + .buttons + ) + button { + padding: 0; +} + +:is( + :is( + :is( + .annotationEditorLayer + :is( + .freeTextEditor, + .inkEditor, + .stampEditor, + .highlightEditor, + .signatureEditor + ), + .textLayer + ) + .editToolbar + ) + .buttons + ) + .divider { + width: 0; + height: calc( + 2 * var(--editor-toolbar-padding) + var(--editor-toolbar-height) + ); + border-left: 1px solid var(--editor-toolbar-border-color); + border-right: none; + display: inline-block; + margin-inline: 2px; +} + +:is( + :is( + :is( + .annotationEditorLayer + :is( + .freeTextEditor, + .inkEditor, + .stampEditor, + .highlightEditor, + .signatureEditor + ), + .textLayer + ) + .editToolbar + ) + .buttons + ) + .basic { + width: var(--editor-toolbar-height); +} + +:is( + :is( + :is( + :is( + .annotationEditorLayer + :is( + .freeTextEditor, + .inkEditor, + .stampEditor, + .highlightEditor, + .signatureEditor + ), + .textLayer + ) + .editToolbar + ) + .buttons + ) + .basic +)::before { + content: ''; + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; + -webkit-mask-position: center; + mask-position: center; + display: inline-block; + background-color: var(--editor-toolbar-fg-color); + width: 100%; + height: 100%; +} + +:is( + :is( + :is( + :is( + .annotationEditorLayer + :is( + .freeTextEditor, + .inkEditor, + .stampEditor, + .highlightEditor, + .signatureEditor + ), + .textLayer + ) + .editToolbar + ) + .buttons + ) + .basic + ):hover::before { + background-color: var(--editor-toolbar-hover-fg-color); +} + +.highlightButton:is( + :is( + :is( + :is( + .annotationEditorLayer + :is( + .freeTextEditor, + .inkEditor, + .stampEditor, + .highlightEditor, + .signatureEditor + ), + .textLayer + ) + .editToolbar + ) + .buttons + ) + .basic + )::before { + -webkit-mask-image: var(--editor-toolbar-highlight-image); + mask-image: var(--editor-toolbar-highlight-image); +} + +.commentButton:is( + :is( + :is( + :is( + .annotationEditorLayer + :is( + .freeTextEditor, + .inkEditor, + .stampEditor, + .highlightEditor, + .signatureEditor + ), + .textLayer + ) + .editToolbar + ) + .buttons + ) + .basic + )::before { + -webkit-mask-image: var(--comment-edit-button-icon); + mask-image: var(--comment-edit-button-icon); +} + +.deleteButton:is( + :is( + :is( + :is( + .annotationEditorLayer + :is( + .freeTextEditor, + .inkEditor, + .stampEditor, + .highlightEditor, + .signatureEditor + ), + .textLayer + ) + .editToolbar + ) + .buttons + ) + .basic + )::before { + -webkit-mask-image: var(--editor-toolbar-delete-image); + mask-image: var(--editor-toolbar-delete-image); +} + +:is( + :is( + :is( + .annotationEditorLayer + :is( + .freeTextEditor, + .inkEditor, + .stampEditor, + .highlightEditor, + .signatureEditor + ), + .textLayer + ) + .editToolbar + ) + .buttons + ) + > * { + height: var(--editor-toolbar-height); +} + +:is( + :is( + :is( + .annotationEditorLayer + :is( + .freeTextEditor, + .inkEditor, + .stampEditor, + .highlightEditor, + .signatureEditor + ), + .textLayer + ) + .editToolbar + ) + .buttons + ) + > :not(.divider) { + border: none; + background-color: transparent; + cursor: pointer; +} + +:is( + :is( + :is( + :is( + .annotationEditorLayer + :is( + .freeTextEditor, + .inkEditor, + .stampEditor, + .highlightEditor, + .signatureEditor + ), + .textLayer + ) + .editToolbar + ) + .buttons + ) + > :not(.divider) +):hover { + border-radius: 2px; + background-color: var(--editor-toolbar-hover-bg-color); + color: var(--editor-toolbar-hover-fg-color); + outline: var(--editor-toolbar-hover-outline); + outline-offset: 1px; +} + +:is( + :is( + :is( + :is( + .annotationEditorLayer + :is( + .freeTextEditor, + .inkEditor, + .stampEditor, + .highlightEditor, + .signatureEditor + ), + .textLayer + ) + .editToolbar + ) + .buttons + ) + > :not(.divider) + ):hover:active { + outline: none; +} + +:is( + :is( + :is( + :is( + .annotationEditorLayer + :is( + .freeTextEditor, + .inkEditor, + .stampEditor, + .highlightEditor, + .signatureEditor + ), + .textLayer + ) + .editToolbar + ) + .buttons + ) + > :not(.divider) +):focus-visible { + border-radius: 2px; + outline: 2px solid var(--editor-toolbar-focus-outline-color); +} + +:is( + :is( + :is( + .annotationEditorLayer + :is( + .freeTextEditor, + .inkEditor, + .stampEditor, + .highlightEditor, + .signatureEditor + ), + .textLayer + ) + .editToolbar + ) + .buttons + ) + .altText { + --alt-text-add-image: url(images/altText_add.svg); + --alt-text-done-image: url(images/altText_done.svg); + + display: flex; + align-items: center; + justify-content: center; + width: -moz-max-content; + width: max-content; + padding-inline: 8px; + pointer-events: all; + font: menu; + font-weight: 590; + font-size: 12px; + color: var(--editor-toolbar-fg-color); +} + +:is( + :is( + :is( + :is( + .annotationEditorLayer + :is( + .freeTextEditor, + .inkEditor, + .stampEditor, + .highlightEditor, + .signatureEditor + ), + .textLayer + ) + .editToolbar + ) + .buttons + ) + .altText +):disabled { + pointer-events: none; +} + +:is( + :is( + :is( + :is( + .annotationEditorLayer + :is( + .freeTextEditor, + .inkEditor, + .stampEditor, + .highlightEditor, + .signatureEditor + ), + .textLayer + ) + .editToolbar + ) + .buttons + ) + .altText +)::before { + content: ''; + -webkit-mask-image: var(--alt-text-add-image); + mask-image: var(--alt-text-add-image); + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; + -webkit-mask-position: center; + mask-position: center; + display: inline-block; + width: 12px; + height: 13px; + background-color: var(--editor-toolbar-fg-color); + margin-inline-end: 4px; +} + +:is( + :is( + :is( + :is( + .annotationEditorLayer + :is( + .freeTextEditor, + .inkEditor, + .stampEditor, + .highlightEditor, + .signatureEditor + ), + .textLayer + ) + .editToolbar + ) + .buttons + ) + .altText + ):hover::before { + background-color: var(--editor-toolbar-hover-fg-color); +} + +.done:is( + :is( + :is( + :is( + .annotationEditorLayer + :is( + .freeTextEditor, + .inkEditor, + .stampEditor, + .highlightEditor, + .signatureEditor + ), + .textLayer + ) + .editToolbar + ) + .buttons + ) + .altText + )::before { + -webkit-mask-image: var(--alt-text-done-image); + mask-image: var(--alt-text-done-image); +} + +.new:is( + :is( + :is( + :is( + .annotationEditorLayer + :is( + .freeTextEditor, + .inkEditor, + .stampEditor, + .highlightEditor, + .signatureEditor + ), + .textLayer + ) + .editToolbar + ) + .buttons + ) + .altText + )::before { + width: 16px; + height: 16px; + -webkit-mask-image: var(--new-alt-text-warning-image); + mask-image: var(--new-alt-text-warning-image); + background-color: var(--alt-text-warning-color); + -webkit-mask-size: cover; + mask-size: cover; +} + +.new:is( + :is( + :is( + :is( + .annotationEditorLayer + :is( + .freeTextEditor, + .inkEditor, + .stampEditor, + .highlightEditor, + .signatureEditor + ), + .textLayer + ) + .editToolbar + ) + .buttons + ) + .altText + ):hover::before { + background-color: var(--alt-text-hover-warning-color); +} + +.new.done:is( + :is( + :is( + :is( + .annotationEditorLayer + :is( + .freeTextEditor, + .inkEditor, + .stampEditor, + .highlightEditor, + .signatureEditor + ), + .textLayer + ) + .editToolbar + ) + .buttons + ) + .altText + )::before { + -webkit-mask-image: var(--alt-text-done-image); + mask-image: var(--alt-text-done-image); + background-color: var(--alt-text-done-color); +} + +.new.done:is( + :is( + :is( + :is( + .annotationEditorLayer + :is( + .freeTextEditor, + .inkEditor, + .stampEditor, + .highlightEditor, + .signatureEditor + ), + .textLayer + ) + .editToolbar + ) + .buttons + ) + .altText + ):hover::before { + background-color: var(--alt-text-hover-done-color); +} + +:is( + :is( + :is( + :is( + .annotationEditorLayer + :is( + .freeTextEditor, + .inkEditor, + .stampEditor, + .highlightEditor, + .signatureEditor + ), + .textLayer + ) + .editToolbar + ) + .buttons + ) + .altText + ) + .tooltip { + display: none; + word-wrap: anywhere; +} + +.show:is( + :is( + :is( + :is( + :is( + .annotationEditorLayer + :is( + .freeTextEditor, + .inkEditor, + .stampEditor, + .highlightEditor, + .signatureEditor + ), + .textLayer + ) + .editToolbar + ) + .buttons + ) + .altText + ) + .tooltip +) { + --csstools-light-dark-toggle--111: var(--csstools-color-scheme--light) #1c1b22; + --alt-text-tooltip-bg: var(--csstools-light-dark-toggle--111, #f0f0f4); + --csstools-light-dark-toggle--112: var(--csstools-color-scheme--light) #fbfbfe; + --alt-text-tooltip-fg: var(--csstools-light-dark-toggle--112, #15141a); + --alt-text-tooltip-border: #8f8f9d; + --csstools-light-dark-toggle--113: var(--csstools-color-scheme--light) #15141a; + --alt-text-tooltip-shadow: 0 2px 6px 0 + var(--csstools-light-dark-toggle--113, rgb(58 57 68 / 0.2)); +} + +@supports (color: light-dark(red, red)) { + .show:is( + :is( + :is( + :is( + :is( + .annotationEditorLayer + :is( + .freeTextEditor, + .inkEditor, + .stampEditor, + .highlightEditor, + .signatureEditor + ), + .textLayer + ) + .editToolbar + ) + .buttons + ) + .altText + ) + .tooltip + ) { + --alt-text-tooltip-bg: light-dark(#f0f0f4, #1c1b22); + --alt-text-tooltip-fg: light-dark(#15141a, #fbfbfe); + } +} + +@supports (color: light-dark(red, red)) and (color: rgb(0 0 0 / 0)) { + .show:is( + :is( + :is( + :is( + :is( + .annotationEditorLayer + :is( + .freeTextEditor, + .inkEditor, + .stampEditor, + .highlightEditor, + .signatureEditor + ), + .textLayer + ) + .editToolbar + ) + .buttons + ) + .altText + ) + .tooltip + ) { + --alt-text-tooltip-shadow: 0 2px 6px 0 + light-dark(rgb(58 57 68 / 0.2), #15141a); + } +} + +@supports not (color: light-dark(tan, tan)) { + .show:is( + :is( + :is( + :is( + :is( + .annotationEditorLayer + :is( + .freeTextEditor, + .inkEditor, + .stampEditor, + .highlightEditor, + .signatureEditor + ), + .textLayer + ) + .editToolbar + ) + .buttons + ) + .altText + ) + .tooltip + ) + * { + --csstools-light-dark-toggle--111: var(--csstools-color-scheme--light) + #1c1b22; + --alt-text-tooltip-bg: var(--csstools-light-dark-toggle--111, #f0f0f4); + --csstools-light-dark-toggle--112: var(--csstools-color-scheme--light) + #fbfbfe; + --alt-text-tooltip-fg: var(--csstools-light-dark-toggle--112, #15141a); + --csstools-light-dark-toggle--113: var(--csstools-color-scheme--light) + #15141a; + --alt-text-tooltip-shadow: 0 2px 6px 0 + var(--csstools-light-dark-toggle--113, rgb(58 57 68 / 0.2)); + } +} + +@media screen and (forced-colors: active) { + .show:is( + :is( + :is( + :is( + :is( + .annotationEditorLayer + :is( + .freeTextEditor, + .inkEditor, + .stampEditor, + .highlightEditor, + .signatureEditor + ), + .textLayer + ) + .editToolbar + ) + .buttons + ) + .altText + ) + .tooltip + ) { + --alt-text-tooltip-bg: Canvas; + --alt-text-tooltip-fg: CanvasText; + --alt-text-tooltip-border: CanvasText; + --alt-text-tooltip-shadow: none; + } +} + +.show:is( + :is( + :is( + :is( + :is( + .annotationEditorLayer + :is( + .freeTextEditor, + .inkEditor, + .stampEditor, + .highlightEditor, + .signatureEditor + ), + .textLayer + ) + .editToolbar + ) + .buttons + ) + .altText + ) + .tooltip +) { + display: inline-flex; + flex-direction: column; + align-items: center; + justify-content: center; + position: absolute; + top: calc(100% + 2px); + inset-inline-start: 0; + padding-block: 2px 3px; + padding-inline: 3px; + max-width: 300px; + width: -moz-max-content; + width: max-content; + height: auto; + font-size: 12px; + + border: 0.5px solid var(--alt-text-tooltip-border); + background: var(--alt-text-tooltip-bg); + box-shadow: var(--alt-text-tooltip-shadow); + color: var(--alt-text-tooltip-fg); + + pointer-events: none; +} + +:is( + :is( + :is( + .annotationEditorLayer + :is( + .freeTextEditor, + .inkEditor, + .stampEditor, + .highlightEditor, + .signatureEditor + ), + .textLayer + ) + .editToolbar + ) + .buttons + ) + .comment { + width: var(--editor-toolbar-height); +} + +:is( + :is( + :is( + :is( + .annotationEditorLayer + :is( + .freeTextEditor, + .inkEditor, + .stampEditor, + .highlightEditor, + .signatureEditor + ), + .textLayer + ) + .editToolbar + ) + .buttons + ) + .comment +)::before { + content: ''; + -webkit-mask-image: var(--comment-edit-button-icon); + mask-image: var(--comment-edit-button-icon); + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; + -webkit-mask-position: center; + mask-position: center; + display: inline-block; + background-color: var(--editor-toolbar-fg-color); + width: 100%; + height: 100%; +} + +.annotationEditorLayer .freeTextEditor { + padding: calc(var(--freetext-padding) * var(--total-scale-factor)); + width: auto; + height: auto; + touch-action: none; +} + +.annotationEditorLayer .freeTextEditor .internal { + background: transparent; + border: none; + inset: 0; + overflow: visible; + white-space: nowrap; + font: 10px sans-serif; + line-height: var(--freetext-line-height); + text-align: start; + -webkit-user-select: none; + -moz-user-select: none; + user-select: none; +} + +.annotationEditorLayer .freeTextEditor .overlay { + position: absolute; + display: none; + background: transparent; + inset: 0; + width: 100%; + height: 100%; +} + +.annotationEditorLayer freeTextEditor .overlay.enabled { + display: block; +} + +.annotationEditorLayer .freeTextEditor .internal:empty::before { + content: attr(default-content); + color: gray; +} + +.annotationEditorLayer .freeTextEditor .internal:focus { + outline: none; + -webkit-user-select: auto; + -moz-user-select: auto; + user-select: auto; +} + +.annotationEditorLayer .inkEditor { + width: 100%; + height: 100%; +} + +.annotationEditorLayer .inkEditor.editing { + cursor: inherit; +} + +.annotationEditorLayer .inkEditor .inkEditorCanvas { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + touch-action: none; +} + +.annotationEditorLayer .stampEditor { + width: auto; + height: auto; +} + +:is(.annotationEditorLayer .stampEditor) canvas { + position: absolute; + width: 100%; + height: 100%; + margin: 0; + top: 0; + left: 0; +} + +:is(.annotationEditorLayer .stampEditor) .noAltTextBadge { + --csstools-light-dark-toggle--114: var(--csstools-color-scheme--light) #52525e; + --no-alt-text-badge-border-color: var( + --csstools-light-dark-toggle--114, + #f0f0f4 + ); + --csstools-light-dark-toggle--115: var(--csstools-color-scheme--light) #fbfbfe; + --no-alt-text-badge-bg-color: var(--csstools-light-dark-toggle--115, #cfcfd8); + --csstools-light-dark-toggle--116: var(--csstools-color-scheme--light) #15141a; + --no-alt-text-badge-fg-color: var(--csstools-light-dark-toggle--116, #5b5b66); +} + +@supports (color: light-dark(red, red)) { + :is(.annotationEditorLayer .stampEditor) .noAltTextBadge { + --no-alt-text-badge-border-color: light-dark(#f0f0f4, #52525e); + --no-alt-text-badge-bg-color: light-dark(#cfcfd8, #fbfbfe); + --no-alt-text-badge-fg-color: light-dark(#5b5b66, #15141a); + } +} + +@supports not (color: light-dark(tan, tan)) { + :is(:is(.annotationEditorLayer .stampEditor) .noAltTextBadge) * { + --csstools-light-dark-toggle--114: var(--csstools-color-scheme--light) + #52525e; + --no-alt-text-badge-border-color: var( + --csstools-light-dark-toggle--114, + #f0f0f4 + ); + --csstools-light-dark-toggle--115: var(--csstools-color-scheme--light) + #fbfbfe; + --no-alt-text-badge-bg-color: var( + --csstools-light-dark-toggle--115, + #cfcfd8 + ); + --csstools-light-dark-toggle--116: var(--csstools-color-scheme--light) + #15141a; + --no-alt-text-badge-fg-color: var( + --csstools-light-dark-toggle--116, + #5b5b66 + ); + } +} + +@media screen and (forced-colors: active) { + :is(.annotationEditorLayer .stampEditor) .noAltTextBadge { + --no-alt-text-badge-border-color: ButtonText; + --no-alt-text-badge-bg-color: ButtonFace; + --no-alt-text-badge-fg-color: ButtonText; + } +} + +:is(.annotationEditorLayer .stampEditor) .noAltTextBadge { + position: absolute; + inset-inline-end: 5px; + inset-block-end: 5px; + display: inline-flex; + width: 32px; + height: 32px; + padding: 3px; + justify-content: center; + align-items: center; + pointer-events: none; + z-index: 1; + + border-radius: 2px; + border: 1px solid var(--no-alt-text-badge-border-color); + background: var(--no-alt-text-badge-bg-color); +} + +:is(:is(.annotationEditorLayer .stampEditor) .noAltTextBadge)::before { + content: ''; + display: inline-block; + width: 16px; + height: 16px; + -webkit-mask-image: var(--new-alt-text-warning-image); + mask-image: var(--new-alt-text-warning-image); + -webkit-mask-size: cover; + mask-size: cover; + background-color: var(--no-alt-text-badge-fg-color); +} + +:is( + .annotationEditorLayer + :is(.freeTextEditor, .inkEditor, .stampEditor, .signatureEditor) + ) + > .resizers { + position: absolute; + inset: 0; + z-index: 1; +} + +.hidden:is( + :is( + .annotationEditorLayer + :is(.freeTextEditor, .inkEditor, .stampEditor, .signatureEditor) + ) + > .resizers +) { + display: none; +} + +:is( + :is( + .annotationEditorLayer + :is(.freeTextEditor, .inkEditor, .stampEditor, .signatureEditor) + ) + > .resizers + ) + > .resizer { + width: var(--resizer-size); + height: var(--resizer-size); + background: content-box var(--resizer-bg-color); + border: var(--focus-outline-around); + border-radius: 2px; + position: absolute; +} + +.topLeft:is( + :is( + :is( + .annotationEditorLayer + :is(.freeTextEditor, .inkEditor, .stampEditor, .signatureEditor) + ) + > .resizers + ) + > .resizer +) { + top: var(--resizer-shift); + left: var(--resizer-shift); +} + +.topMiddle:is( + :is( + :is( + .annotationEditorLayer + :is(.freeTextEditor, .inkEditor, .stampEditor, .signatureEditor) + ) + > .resizers + ) + > .resizer +) { + top: var(--resizer-shift); + left: calc(50% + var(--resizer-shift)); +} + +.topRight:is( + :is( + :is( + .annotationEditorLayer + :is(.freeTextEditor, .inkEditor, .stampEditor, .signatureEditor) + ) + > .resizers + ) + > .resizer +) { + top: var(--resizer-shift); + right: var(--resizer-shift); +} + +.middleRight:is( + :is( + :is( + .annotationEditorLayer + :is(.freeTextEditor, .inkEditor, .stampEditor, .signatureEditor) + ) + > .resizers + ) + > .resizer +) { + top: calc(50% + var(--resizer-shift)); + right: var(--resizer-shift); +} + +.bottomRight:is( + :is( + :is( + .annotationEditorLayer + :is(.freeTextEditor, .inkEditor, .stampEditor, .signatureEditor) + ) + > .resizers + ) + > .resizer +) { + bottom: var(--resizer-shift); + right: var(--resizer-shift); +} + +.bottomMiddle:is( + :is( + :is( + .annotationEditorLayer + :is(.freeTextEditor, .inkEditor, .stampEditor, .signatureEditor) + ) + > .resizers + ) + > .resizer +) { + bottom: var(--resizer-shift); + left: calc(50% + var(--resizer-shift)); +} + +.bottomLeft:is( + :is( + :is( + .annotationEditorLayer + :is(.freeTextEditor, .inkEditor, .stampEditor, .signatureEditor) + ) + > .resizers + ) + > .resizer +) { + bottom: var(--resizer-shift); + left: var(--resizer-shift); +} + +.middleLeft:is( + :is( + :is( + .annotationEditorLayer + :is(.freeTextEditor, .inkEditor, .stampEditor, .signatureEditor) + ) + > .resizers + ) + > .resizer +) { + top: calc(50% + var(--resizer-shift)); + left: var(--resizer-shift); +} + +.topLeft:is( + :is( + .annotationEditorLayer[data-main-rotation='0'] + :is([data-editor-rotation='0'], [data-editor-rotation='180']), + .annotationEditorLayer[data-main-rotation='90'] + :is([data-editor-rotation='270'], [data-editor-rotation='90']), + .annotationEditorLayer[data-main-rotation='180'] + :is([data-editor-rotation='180'], [data-editor-rotation='0']), + .annotationEditorLayer[data-main-rotation='270'] + :is([data-editor-rotation='90'], [data-editor-rotation='270']) + ) + > .resizers + > .resizer +), +.bottomRight:is( + :is( + .annotationEditorLayer[data-main-rotation='0'] + :is([data-editor-rotation='0'], [data-editor-rotation='180']), + .annotationEditorLayer[data-main-rotation='90'] + :is([data-editor-rotation='270'], [data-editor-rotation='90']), + .annotationEditorLayer[data-main-rotation='180'] + :is([data-editor-rotation='180'], [data-editor-rotation='0']), + .annotationEditorLayer[data-main-rotation='270'] + :is([data-editor-rotation='90'], [data-editor-rotation='270']) + ) + > .resizers + > .resizer +) { + cursor: nwse-resize; +} + +.topMiddle:is( + :is( + .annotationEditorLayer[data-main-rotation='0'] + :is([data-editor-rotation='0'], [data-editor-rotation='180']), + .annotationEditorLayer[data-main-rotation='90'] + :is([data-editor-rotation='270'], [data-editor-rotation='90']), + .annotationEditorLayer[data-main-rotation='180'] + :is([data-editor-rotation='180'], [data-editor-rotation='0']), + .annotationEditorLayer[data-main-rotation='270'] + :is([data-editor-rotation='90'], [data-editor-rotation='270']) + ) + > .resizers + > .resizer +), +.bottomMiddle:is( + :is( + .annotationEditorLayer[data-main-rotation='0'] + :is([data-editor-rotation='0'], [data-editor-rotation='180']), + .annotationEditorLayer[data-main-rotation='90'] + :is([data-editor-rotation='270'], [data-editor-rotation='90']), + .annotationEditorLayer[data-main-rotation='180'] + :is([data-editor-rotation='180'], [data-editor-rotation='0']), + .annotationEditorLayer[data-main-rotation='270'] + :is([data-editor-rotation='90'], [data-editor-rotation='270']) + ) + > .resizers + > .resizer +) { + cursor: ns-resize; +} + +.topRight:is( + :is( + .annotationEditorLayer[data-main-rotation='0'] + :is([data-editor-rotation='0'], [data-editor-rotation='180']), + .annotationEditorLayer[data-main-rotation='90'] + :is([data-editor-rotation='270'], [data-editor-rotation='90']), + .annotationEditorLayer[data-main-rotation='180'] + :is([data-editor-rotation='180'], [data-editor-rotation='0']), + .annotationEditorLayer[data-main-rotation='270'] + :is([data-editor-rotation='90'], [data-editor-rotation='270']) + ) + > .resizers + > .resizer +), +.bottomLeft:is( + :is( + .annotationEditorLayer[data-main-rotation='0'] + :is([data-editor-rotation='0'], [data-editor-rotation='180']), + .annotationEditorLayer[data-main-rotation='90'] + :is([data-editor-rotation='270'], [data-editor-rotation='90']), + .annotationEditorLayer[data-main-rotation='180'] + :is([data-editor-rotation='180'], [data-editor-rotation='0']), + .annotationEditorLayer[data-main-rotation='270'] + :is([data-editor-rotation='90'], [data-editor-rotation='270']) + ) + > .resizers + > .resizer +) { + cursor: nesw-resize; +} + +.middleRight:is( + :is( + .annotationEditorLayer[data-main-rotation='0'] + :is([data-editor-rotation='0'], [data-editor-rotation='180']), + .annotationEditorLayer[data-main-rotation='90'] + :is([data-editor-rotation='270'], [data-editor-rotation='90']), + .annotationEditorLayer[data-main-rotation='180'] + :is([data-editor-rotation='180'], [data-editor-rotation='0']), + .annotationEditorLayer[data-main-rotation='270'] + :is([data-editor-rotation='90'], [data-editor-rotation='270']) + ) + > .resizers + > .resizer +), +.middleLeft:is( + :is( + .annotationEditorLayer[data-main-rotation='0'] + :is([data-editor-rotation='0'], [data-editor-rotation='180']), + .annotationEditorLayer[data-main-rotation='90'] + :is([data-editor-rotation='270'], [data-editor-rotation='90']), + .annotationEditorLayer[data-main-rotation='180'] + :is([data-editor-rotation='180'], [data-editor-rotation='0']), + .annotationEditorLayer[data-main-rotation='270'] + :is([data-editor-rotation='90'], [data-editor-rotation='270']) + ) + > .resizers + > .resizer +) { + cursor: ew-resize; +} + +.topLeft:is( + :is( + .annotationEditorLayer[data-main-rotation='0'] + :is([data-editor-rotation='90'], [data-editor-rotation='270']), + .annotationEditorLayer[data-main-rotation='90'] + :is([data-editor-rotation='0'], [data-editor-rotation='180']), + .annotationEditorLayer[data-main-rotation='180'] + :is([data-editor-rotation='270'], [data-editor-rotation='90']), + .annotationEditorLayer[data-main-rotation='270'] + :is([data-editor-rotation='180'], [data-editor-rotation='0']) + ) + > .resizers + > .resizer +), +.bottomRight:is( + :is( + .annotationEditorLayer[data-main-rotation='0'] + :is([data-editor-rotation='90'], [data-editor-rotation='270']), + .annotationEditorLayer[data-main-rotation='90'] + :is([data-editor-rotation='0'], [data-editor-rotation='180']), + .annotationEditorLayer[data-main-rotation='180'] + :is([data-editor-rotation='270'], [data-editor-rotation='90']), + .annotationEditorLayer[data-main-rotation='270'] + :is([data-editor-rotation='180'], [data-editor-rotation='0']) + ) + > .resizers + > .resizer +) { + cursor: nesw-resize; +} + +.topMiddle:is( + :is( + .annotationEditorLayer[data-main-rotation='0'] + :is([data-editor-rotation='90'], [data-editor-rotation='270']), + .annotationEditorLayer[data-main-rotation='90'] + :is([data-editor-rotation='0'], [data-editor-rotation='180']), + .annotationEditorLayer[data-main-rotation='180'] + :is([data-editor-rotation='270'], [data-editor-rotation='90']), + .annotationEditorLayer[data-main-rotation='270'] + :is([data-editor-rotation='180'], [data-editor-rotation='0']) + ) + > .resizers + > .resizer +), +.bottomMiddle:is( + :is( + .annotationEditorLayer[data-main-rotation='0'] + :is([data-editor-rotation='90'], [data-editor-rotation='270']), + .annotationEditorLayer[data-main-rotation='90'] + :is([data-editor-rotation='0'], [data-editor-rotation='180']), + .annotationEditorLayer[data-main-rotation='180'] + :is([data-editor-rotation='270'], [data-editor-rotation='90']), + .annotationEditorLayer[data-main-rotation='270'] + :is([data-editor-rotation='180'], [data-editor-rotation='0']) + ) + > .resizers + > .resizer +) { + cursor: ew-resize; +} + +.topRight:is( + :is( + .annotationEditorLayer[data-main-rotation='0'] + :is([data-editor-rotation='90'], [data-editor-rotation='270']), + .annotationEditorLayer[data-main-rotation='90'] + :is([data-editor-rotation='0'], [data-editor-rotation='180']), + .annotationEditorLayer[data-main-rotation='180'] + :is([data-editor-rotation='270'], [data-editor-rotation='90']), + .annotationEditorLayer[data-main-rotation='270'] + :is([data-editor-rotation='180'], [data-editor-rotation='0']) + ) + > .resizers + > .resizer +), +.bottomLeft:is( + :is( + .annotationEditorLayer[data-main-rotation='0'] + :is([data-editor-rotation='90'], [data-editor-rotation='270']), + .annotationEditorLayer[data-main-rotation='90'] + :is([data-editor-rotation='0'], [data-editor-rotation='180']), + .annotationEditorLayer[data-main-rotation='180'] + :is([data-editor-rotation='270'], [data-editor-rotation='90']), + .annotationEditorLayer[data-main-rotation='270'] + :is([data-editor-rotation='180'], [data-editor-rotation='0']) + ) + > .resizers + > .resizer +) { + cursor: nwse-resize; +} + +.middleRight:is( + :is( + .annotationEditorLayer[data-main-rotation='0'] + :is([data-editor-rotation='90'], [data-editor-rotation='270']), + .annotationEditorLayer[data-main-rotation='90'] + :is([data-editor-rotation='0'], [data-editor-rotation='180']), + .annotationEditorLayer[data-main-rotation='180'] + :is([data-editor-rotation='270'], [data-editor-rotation='90']), + .annotationEditorLayer[data-main-rotation='270'] + :is([data-editor-rotation='180'], [data-editor-rotation='0']) + ) + > .resizers + > .resizer +), +.middleLeft:is( + :is( + .annotationEditorLayer[data-main-rotation='0'] + :is([data-editor-rotation='90'], [data-editor-rotation='270']), + .annotationEditorLayer[data-main-rotation='90'] + :is([data-editor-rotation='0'], [data-editor-rotation='180']), + .annotationEditorLayer[data-main-rotation='180'] + :is([data-editor-rotation='270'], [data-editor-rotation='90']), + .annotationEditorLayer[data-main-rotation='270'] + :is([data-editor-rotation='180'], [data-editor-rotation='0']) + ) + > .resizers + > .resizer +) { + cursor: ns-resize; +} + +:is( + .annotationEditorLayer + :is( + [data-main-rotation='0'] [data-editor-rotation='90'], + [data-main-rotation='90'] [data-editor-rotation='0'], + [data-main-rotation='180'] [data-editor-rotation='270'], + [data-main-rotation='270'] [data-editor-rotation='180'] + ) + ) + .editToolbar { + rotate: 270deg; +} + +[dir='ltr'] + :is( + :is( + .annotationEditorLayer + :is( + [data-main-rotation='0'] [data-editor-rotation='90'], + [data-main-rotation='90'] [data-editor-rotation='0'], + [data-main-rotation='180'] [data-editor-rotation='270'], + [data-main-rotation='270'] [data-editor-rotation='180'] + ) + ) + .editToolbar + ) { + inset-inline-end: calc(0px - var(--editor-toolbar-vert-offset)); + inset-block-start: 0; +} + +[dir='rtl'] + :is( + :is( + .annotationEditorLayer + :is( + [data-main-rotation='0'] [data-editor-rotation='90'], + [data-main-rotation='90'] [data-editor-rotation='0'], + [data-main-rotation='180'] [data-editor-rotation='270'], + [data-main-rotation='270'] [data-editor-rotation='180'] + ) + ) + .editToolbar + ) { + inset-inline-end: calc(100% + var(--editor-toolbar-vert-offset)); + inset-block-start: 0; +} + +:is( + .annotationEditorLayer + :is( + [data-main-rotation='0'] [data-editor-rotation='180'], + [data-main-rotation='90'] [data-editor-rotation='90'], + [data-main-rotation='180'] [data-editor-rotation='0'], + [data-main-rotation='270'] [data-editor-rotation='270'] + ) + ) + .editToolbar { + rotate: 180deg; + inset-inline-end: 100%; + inset-block-start: calc(0px - var(--editor-toolbar-vert-offset)); +} + +:is( + .annotationEditorLayer + :is( + [data-main-rotation='0'] [data-editor-rotation='270'], + [data-main-rotation='90'] [data-editor-rotation='180'], + [data-main-rotation='180'] [data-editor-rotation='90'], + [data-main-rotation='270'] [data-editor-rotation='0'] + ) + ) + .editToolbar { + rotate: 90deg; +} + +[dir='ltr'] + :is( + :is( + .annotationEditorLayer + :is( + [data-main-rotation='0'] [data-editor-rotation='270'], + [data-main-rotation='90'] [data-editor-rotation='180'], + [data-main-rotation='180'] [data-editor-rotation='90'], + [data-main-rotation='270'] [data-editor-rotation='0'] + ) + ) + .editToolbar + ) { + inset-inline-end: calc(100% + var(--editor-toolbar-vert-offset)); + inset-block-start: 100%; +} + +[dir='rtl'] + :is( + :is( + .annotationEditorLayer + :is( + [data-main-rotation='0'] [data-editor-rotation='270'], + [data-main-rotation='90'] [data-editor-rotation='180'], + [data-main-rotation='180'] [data-editor-rotation='90'], + [data-main-rotation='270'] [data-editor-rotation='0'] + ) + ) + .editToolbar + ) { + inset-inline-start: calc(0px - var(--editor-toolbar-vert-offset)); + inset-block-start: 0; +} + +.dialog.altText::backdrop { + -webkit-mask: url(#alttext-manager-mask); + mask: url(#alttext-manager-mask); +} + +.dialog.altText.positioned { + margin: 0; +} + +.dialog.altText #altTextContainer { + width: 300px; + height: -moz-fit-content; + height: fit-content; + display: inline-flex; + flex-direction: column; + align-items: flex-start; + gap: 16px; +} + +:is(.dialog.altText #altTextContainer) #overallDescription { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 4px; + align-self: stretch; +} + +:is(:is(.dialog.altText #altTextContainer) #overallDescription) span { + align-self: stretch; +} + +:is(:is(.dialog.altText #altTextContainer) #overallDescription) .title { + font-size: 13px; + font-style: normal; + font-weight: 590; +} + +:is(.dialog.altText #altTextContainer) #addDescription { + display: flex; + flex-direction: column; + align-items: stretch; + gap: 8px; +} + +:is(:is(.dialog.altText #altTextContainer) #addDescription) .descriptionArea { + flex: 1; + padding-inline: 24px 10px; +} + +:is( + :is(:is(.dialog.altText #altTextContainer) #addDescription) .descriptionArea + ) + textarea { + width: 100%; + min-height: 75px; +} + +:is(.dialog.altText #altTextContainer) #buttons { + display: flex; + justify-content: flex-end; + align-items: flex-start; + gap: 8px; + align-self: stretch; +} + +.dialog.newAltText { + --new-alt-text-ai-disclaimer-icon: url(images/altText_disclaimer.svg); + --new-alt-text-spinner-icon: url(images/altText_spinner.svg); + --csstools-light-dark-toggle--117: var(--csstools-color-scheme--light) #2b2a33; + --preview-image-bg-color: var(--csstools-light-dark-toggle--117, #f0f0f4); + --preview-image-border: none; +} + +@supports (color: light-dark(red, red)) { + .dialog.newAltText { + --preview-image-bg-color: light-dark(#f0f0f4, #2b2a33); + } +} + +@supports not (color: light-dark(tan, tan)) { + .dialog.newAltText * { + --csstools-light-dark-toggle--117: var(--csstools-color-scheme--light) + #2b2a33; + --preview-image-bg-color: var(--csstools-light-dark-toggle--117, #f0f0f4); + } +} + +@media screen and (forced-colors: active) { + .dialog.newAltText { + --preview-image-bg-color: ButtonFace; + --preview-image-border: 1px solid ButtonText; + } +} + +.dialog.newAltText { + width: 80%; + max-width: 570px; + min-width: 300px; + padding: 0; +} + +.dialog.newAltText.noAi #newAltTextDisclaimer, +.dialog.newAltText.noAi #newAltTextCreateAutomatically { + display: none !important; +} + +.dialog.newAltText.aiInstalling #newAltTextCreateAutomatically { + display: none !important; +} + +.dialog.newAltText.aiInstalling #newAltTextDownloadModel { + display: flex !important; +} + +.dialog.newAltText.error #newAltTextNotNow { + display: none !important; +} + +.dialog.newAltText.error #newAltTextCancel { + display: inline-block !important; +} + +.dialog.newAltText:not(.error) #newAltTextError { + display: none !important; +} + +.dialog.newAltText #newAltTextContainer { + display: flex; + width: auto; + padding: 16px; + flex-direction: column; + justify-content: flex-end; + align-items: flex-start; + gap: 12px; + flex: 0 1 auto; + line-height: normal; +} + +:is(.dialog.newAltText #newAltTextContainer) #mainContent { + display: flex; + justify-content: flex-end; + align-items: flex-start; + gap: 12px; + align-self: stretch; + flex: 1 1 auto; +} + +:is(:is(.dialog.newAltText #newAltTextContainer) #mainContent) + #descriptionAndSettings { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 16px; + flex: 1 0 0; + align-self: stretch; +} + +:is(:is(.dialog.newAltText #newAltTextContainer) #mainContent) + #descriptionInstruction { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 8px; + align-self: stretch; + flex: 1 1 auto; +} + +:is( + :is(:is(.dialog.newAltText #newAltTextContainer) #mainContent) + #descriptionInstruction + ) + #newAltTextDescriptionContainer { + width: 100%; + height: 70px; + position: relative; +} + +:is( + :is( + :is(:is(.dialog.newAltText #newAltTextContainer) #mainContent) + #descriptionInstruction + ) + #newAltTextDescriptionContainer + ) + textarea { + width: 100%; + height: 100%; + padding: 8px; +} + +:is( + :is( + :is( + :is(:is(.dialog.newAltText #newAltTextContainer) #mainContent) + #descriptionInstruction + ) + #newAltTextDescriptionContainer + ) + textarea +)::-moz-placeholder { + color: var(--text-secondary-color); +} + +:is( + :is( + :is( + :is(:is(.dialog.newAltText #newAltTextContainer) #mainContent) + #descriptionInstruction + ) + #newAltTextDescriptionContainer + ) + textarea +)::placeholder { + color: var(--text-secondary-color); +} + +:is( + :is( + :is(:is(.dialog.newAltText #newAltTextContainer) #mainContent) + #descriptionInstruction + ) + #newAltTextDescriptionContainer + ) + .altTextSpinner { + display: none; + position: absolute; + width: 16px; + height: 16px; + inset-inline-start: 8px; + inset-block-start: 8px; + -webkit-mask-size: cover; + mask-size: cover; + background-color: var(--text-secondary-color); + pointer-events: none; +} + +.loading:is( + :is( + :is(:is(.dialog.newAltText #newAltTextContainer) #mainContent) + #descriptionInstruction + ) + #newAltTextDescriptionContainer + ) + textarea::-moz-placeholder { + color: transparent; +} + +.loading:is( + :is( + :is(:is(.dialog.newAltText #newAltTextContainer) #mainContent) + #descriptionInstruction + ) + #newAltTextDescriptionContainer + ) + textarea::placeholder { + color: transparent; +} + +.loading:is( + :is( + :is(:is(.dialog.newAltText #newAltTextContainer) #mainContent) + #descriptionInstruction + ) + #newAltTextDescriptionContainer + ) + .altTextSpinner { + display: inline-block; + -webkit-mask-image: var(--new-alt-text-spinner-icon); + mask-image: var(--new-alt-text-spinner-icon); +} + +:is( + :is(:is(.dialog.newAltText #newAltTextContainer) #mainContent) + #descriptionInstruction + ) + #newAltTextDescription { + font-size: 11px; +} + +:is( + :is(:is(.dialog.newAltText #newAltTextContainer) #mainContent) + #descriptionInstruction + ) + #newAltTextDisclaimer { + display: flex; + flex-direction: row; + align-items: flex-start; + gap: 4px; + font-size: 11px; +} + +:is( + :is( + :is(:is(.dialog.newAltText #newAltTextContainer) #mainContent) + #descriptionInstruction + ) + #newAltTextDisclaimer +)::before { + content: ''; + display: inline-block; + width: 17px; + height: 16px; + -webkit-mask-image: var(--new-alt-text-ai-disclaimer-icon); + mask-image: var(--new-alt-text-ai-disclaimer-icon); + -webkit-mask-size: cover; + mask-size: cover; + background-color: var(--text-secondary-color); + flex: 1 0 auto; +} + +:is(:is(.dialog.newAltText #newAltTextContainer) #mainContent) + #newAltTextDownloadModel { + display: flex; + align-items: center; + gap: 4px; + align-self: stretch; +} + +:is( + :is(:is(.dialog.newAltText #newAltTextContainer) #mainContent) + #newAltTextDownloadModel +)::before { + content: ''; + display: inline-block; + width: 16px; + height: 16px; + -webkit-mask-image: var(--new-alt-text-spinner-icon); + mask-image: var(--new-alt-text-spinner-icon); + -webkit-mask-size: cover; + mask-size: cover; + background-color: var(--text-secondary-color); +} + +:is(:is(.dialog.newAltText #newAltTextContainer) #mainContent) + #newAltTextImagePreview { + width: 180px; + aspect-ratio: 1; + display: flex; + justify-content: center; + align-items: center; + flex: 0 0 auto; + background-color: var(--preview-image-bg-color); + border: var(--preview-image-border); +} + +:is( + :is(:is(.dialog.newAltText #newAltTextContainer) #mainContent) + #newAltTextImagePreview + ) + > canvas { + max-width: 100%; + max-height: 100%; +} + +.colorPicker { + --csstools-light-dark-toggle--118: var(--csstools-color-scheme--light) #80ebff; + --hover-outline-color: var(--csstools-light-dark-toggle--118, #0250bb); + --csstools-light-dark-toggle--119: var(--csstools-color-scheme--light) #aaf2ff; + --selected-outline-color: var(--csstools-light-dark-toggle--119, #0060df); + --csstools-light-dark-toggle--120: var(--csstools-color-scheme--light) #52525e; + --swatch-border-color: var(--csstools-light-dark-toggle--120, #cfcfd8); +} + +@supports (color: light-dark(red, red)) { + .colorPicker { + --hover-outline-color: light-dark(#0250bb, #80ebff); + --selected-outline-color: light-dark(#0060df, #aaf2ff); + --swatch-border-color: light-dark(#cfcfd8, #52525e); + } +} + +@supports not (color: light-dark(tan, tan)) { + .colorPicker * { + --csstools-light-dark-toggle--118: var(--csstools-color-scheme--light) + #80ebff; + --hover-outline-color: var(--csstools-light-dark-toggle--118, #0250bb); + --csstools-light-dark-toggle--119: var(--csstools-color-scheme--light) + #aaf2ff; + --selected-outline-color: var(--csstools-light-dark-toggle--119, #0060df); + --csstools-light-dark-toggle--120: var(--csstools-color-scheme--light) + #52525e; + --swatch-border-color: var(--csstools-light-dark-toggle--120, #cfcfd8); + } +} + +@media screen and (forced-colors: active) { + .colorPicker { + --hover-outline-color: Highlight; + --selected-outline-color: var(--hover-outline-color); + --swatch-border-color: ButtonText; + } +} + +.colorPicker .swatch { + width: 16px; + height: 16px; + border: 1px solid var(--swatch-border-color); + border-radius: 100%; + outline-offset: 2px; + box-sizing: border-box; + forced-color-adjust: none; +} + +.colorPicker button:is(:hover, .selected) > .swatch { + border: none; +} + +.basicColorPicker { + width: 28px; +} + +.basicColorPicker::-moz-color-swatch { + border-radius: 100%; +} + +.basicColorPicker::-webkit-color-swatch { + border-radius: 100%; +} + +.annotationEditorLayer[data-main-rotation='0'] + .highlightEditor:not(.free) + > .editToolbar { + rotate: 0deg; +} + +.annotationEditorLayer[data-main-rotation='90'] + .highlightEditor:not(.free) + > .editToolbar { + rotate: 270deg; +} + +.annotationEditorLayer[data-main-rotation='180'] + .highlightEditor:not(.free) + > .editToolbar { + rotate: 180deg; +} + +.annotationEditorLayer[data-main-rotation='270'] + .highlightEditor:not(.free) + > .editToolbar { + rotate: 90deg; +} + +.annotationEditorLayer .highlightEditor { + position: absolute; + background: transparent; + z-index: 1; + cursor: auto; + max-width: 100%; + max-height: 100%; + border: none; + outline: none; + pointer-events: none; + transform-origin: 0 0; +} + +:is(.annotationEditorLayer .highlightEditor):not(.free) { + transform: none; +} + +:is(.annotationEditorLayer .highlightEditor) .internal { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + pointer-events: auto; +} + +.disabled:is(.annotationEditorLayer .highlightEditor) .internal { + pointer-events: none; +} + +.selectedEditor:is(.annotationEditorLayer .highlightEditor) .internal { + cursor: pointer; +} + +:is(.annotationEditorLayer .highlightEditor) .editToolbar { + --editor-toolbar-colorpicker-arrow-image: url(images/toolbarButton-menuArrow.svg); + + transform-origin: center !important; +} + +:is(:is(:is(.annotationEditorLayer .highlightEditor) .editToolbar) .buttons) + .colorPicker { + position: relative; + width: auto; + display: flex; + justify-content: center; + align-items: center; + gap: 4px; + padding: 4px; +} + +:is( + :is(:is(:is(.annotationEditorLayer .highlightEditor) .editToolbar) .buttons) + .colorPicker +)::after { + content: ''; + -webkit-mask-image: var(--editor-toolbar-colorpicker-arrow-image); + mask-image: var(--editor-toolbar-colorpicker-arrow-image); + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; + -webkit-mask-position: center; + mask-position: center; + display: inline-block; + background-color: var(--editor-toolbar-fg-color); + width: 12px; + height: 12px; +} + +:is( + :is(:is(:is(.annotationEditorLayer .highlightEditor) .editToolbar) .buttons) + .colorPicker + ):hover::after { + background-color: var(--editor-toolbar-hover-fg-color); +} + +:is( + :is(:is(:is(.annotationEditorLayer .highlightEditor) .editToolbar) .buttons) + .colorPicker +):has(.dropdown:not(.hidden)) { + background-color: var(--editor-toolbar-hover-bg-color); +} + +:is( + :is(:is(:is(.annotationEditorLayer .highlightEditor) .editToolbar) .buttons) + .colorPicker + ):has(.dropdown:not(.hidden))::after { + scale: -1; +} + +:is( + :is(:is(:is(.annotationEditorLayer .highlightEditor) .editToolbar) .buttons) + .colorPicker + ) + .dropdown { + position: absolute; + display: flex; + justify-content: center; + align-items: center; + flex-direction: column; + gap: 11px; + padding-block: 8px; + border-radius: 6px; + background-color: var(--editor-toolbar-bg-color); + border: 1px solid var(--editor-toolbar-border-color); + box-shadow: var(--editor-toolbar-shadow); + inset-block-start: calc(100% + 4px); + width: calc(100% + 2 * var(--editor-toolbar-padding)); +} + +:is( + :is( + :is( + :is(:is(.annotationEditorLayer .highlightEditor) .editToolbar) + .buttons + ) + .colorPicker + ) + .dropdown + ) + button { + width: 100%; + height: auto; + border: none; + cursor: pointer; + display: flex; + justify-content: center; + align-items: center; + background: none; +} + +:is( + :is( + :is( + :is( + :is(:is(.annotationEditorLayer .highlightEditor) .editToolbar) + .buttons + ) + .colorPicker + ) + .dropdown + ) + button +):is(:active, :focus-visible) { + outline: none; +} + +:is( + :is( + :is( + :is( + :is(:is(.annotationEditorLayer .highlightEditor) .editToolbar) + .buttons + ) + .colorPicker + ) + .dropdown + ) + button + ) + > .swatch { + outline-offset: 2px; +} + +[aria-selected='true']:is( + :is( + :is( + :is( + :is(:is(.annotationEditorLayer .highlightEditor) .editToolbar) + .buttons + ) + .colorPicker + ) + .dropdown + ) + button + ) + > .swatch { + outline: 2px solid var(--selected-outline-color); +} + +:is( + :is( + :is( + :is( + :is(:is(.annotationEditorLayer .highlightEditor) .editToolbar) + .buttons + ) + .colorPicker + ) + .dropdown + ) + button + ):is(:hover, :active, :focus-visible) + > .swatch { + outline: 2px solid var(--hover-outline-color); +} + +.editorParamsToolbar:has(#highlightParamsToolbarContainer) { + padding: unset; +} + +#highlightParamsToolbarContainer { + gap: 16px; + padding-inline: 10px; + padding-block-end: 12px; +} + +#highlightParamsToolbarContainer .colorPicker { + display: flex; + flex-direction: column; + gap: 8px; +} + +:is(#highlightParamsToolbarContainer .colorPicker) .dropdown { + display: flex; + justify-content: space-between; + align-items: center; + flex-direction: row; + height: auto; +} + +:is(:is(#highlightParamsToolbarContainer .colorPicker) .dropdown) button { + width: auto; + height: auto; + border: none; + cursor: pointer; + display: flex; + justify-content: center; + align-items: center; + background: none; + flex: 0 0 auto; + padding: 0; +} + +:is(:is(:is(#highlightParamsToolbarContainer .colorPicker) .dropdown) button) + .swatch { + width: 24px; + height: 24px; +} + +:is( + :is(:is(#highlightParamsToolbarContainer .colorPicker) .dropdown) button +):is(:active, :focus-visible) { + outline: none; +} + +[aria-selected='true']:is( + :is(:is(#highlightParamsToolbarContainer .colorPicker) .dropdown) button + ) + > .swatch { + outline: 2px solid var(--selected-outline-color); +} + +:is( + :is(:is(#highlightParamsToolbarContainer .colorPicker) .dropdown) button + ):is(:hover, :active, :focus-visible) + > .swatch { + outline: 2px solid var(--hover-outline-color); +} + +#highlightParamsToolbarContainer #editorHighlightThickness { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + align-self: stretch; +} + +:is(#highlightParamsToolbarContainer #editorHighlightThickness) + .editorParamsLabel { + height: auto; + align-self: stretch; +} + +:is(#highlightParamsToolbarContainer #editorHighlightThickness) + .thicknessPicker { + display: flex; + justify-content: space-between; + align-items: center; + align-self: stretch; + + --csstools-light-dark-toggle--121: var(--csstools-color-scheme--light) #80808e; + + --example-color: var(--csstools-light-dark-toggle--121, #bfbfc9); +} + +@supports (color: light-dark(red, red)) { + :is(#highlightParamsToolbarContainer #editorHighlightThickness) + .thicknessPicker { + --example-color: light-dark(#bfbfc9, #80808e); + } +} + +@supports not (color: light-dark(tan, tan)) { + :is( + :is(#highlightParamsToolbarContainer #editorHighlightThickness) + .thicknessPicker + ) + * { + --csstools-light-dark-toggle--121: var(--csstools-color-scheme--light) + #80808e; + + --example-color: var(--csstools-light-dark-toggle--121, #bfbfc9); + } +} + +@media screen and (forced-colors: active) { + :is(#highlightParamsToolbarContainer #editorHighlightThickness) + .thicknessPicker { + --example-color: CanvasText; + } +} + +:is( + :is( + :is(#highlightParamsToolbarContainer #editorHighlightThickness) + .thicknessPicker + ) + > .editorParamsSlider[disabled] +) { + opacity: 0.4; +} + +:is( + :is(#highlightParamsToolbarContainer #editorHighlightThickness) + .thicknessPicker +)::before, +:is( + :is(#highlightParamsToolbarContainer #editorHighlightThickness) + .thicknessPicker +)::after { + content: ''; + width: 8px; + aspect-ratio: 1; + display: block; + border-radius: 100%; + background-color: var(--example-color); +} + +:is( + :is(#highlightParamsToolbarContainer #editorHighlightThickness) + .thicknessPicker +)::after { + width: 24px; +} + +:is( + :is(#highlightParamsToolbarContainer #editorHighlightThickness) + .thicknessPicker + ) + .editorParamsSlider { + width: unset; + height: 14px; +} + +#highlightParamsToolbarContainer #editorHighlightVisibility { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 8px; + align-self: stretch; +} + +:is(#highlightParamsToolbarContainer #editorHighlightVisibility) .divider { + --csstools-light-dark-toggle--122: var(--csstools-color-scheme--light) #8f8f9d; + --divider-color: var(--csstools-light-dark-toggle--122, #d7d7db); +} + +@supports (color: light-dark(red, red)) { + :is(#highlightParamsToolbarContainer #editorHighlightVisibility) .divider { + --divider-color: light-dark(#d7d7db, #8f8f9d); + } +} + +@supports not (color: light-dark(tan, tan)) { + :is(:is(#highlightParamsToolbarContainer #editorHighlightVisibility) .divider) + * { + --csstools-light-dark-toggle--122: var(--csstools-color-scheme--light) + #8f8f9d; + --divider-color: var(--csstools-light-dark-toggle--122, #d7d7db); + } +} + +@media screen and (forced-colors: active) { + :is(#highlightParamsToolbarContainer #editorHighlightVisibility) .divider { + --divider-color: CanvasText; + } +} + +:is(#highlightParamsToolbarContainer #editorHighlightVisibility) .divider { + margin-block: 4px; + width: 100%; + height: 1px; + background-color: var(--divider-color); +} + +:is(#highlightParamsToolbarContainer #editorHighlightVisibility) .toggler { + display: flex; + justify-content: space-between; + align-items: center; + align-self: stretch; +} + +#altTextSettingsDialog { + padding: 16px; +} + +#altTextSettingsDialog #altTextSettingsContainer { + display: flex; + width: 573px; + flex-direction: column; + gap: 16px; +} + +:is(#altTextSettingsDialog #altTextSettingsContainer) .mainContainer { + gap: 16px; +} + +:is(#altTextSettingsDialog #altTextSettingsContainer) .description { + color: var(--text-secondary-color); +} + +:is(#altTextSettingsDialog #altTextSettingsContainer) #aiModelSettings { + display: flex; + flex-direction: column; + gap: 12px; +} + +:is(:is(#altTextSettingsDialog #altTextSettingsContainer) #aiModelSettings) + button { + width: -moz-fit-content; + width: fit-content; +} + +.download:is( + :is(#altTextSettingsDialog #altTextSettingsContainer) #aiModelSettings + ) + #deleteModelButton { + display: none; +} + +:is(:is(#altTextSettingsDialog #altTextSettingsContainer) #aiModelSettings):not( + .download + ) + #downloadModelButton { + display: none; +} + +:is(#altTextSettingsDialog #altTextSettingsContainer) #automaticAltText, +:is(#altTextSettingsDialog #altTextSettingsContainer) #altTextEditor { + display: flex; + flex-direction: column; + gap: 8px; +} + +:is(#altTextSettingsDialog #altTextSettingsContainer) #createModelDescription, +:is(#altTextSettingsDialog #altTextSettingsContainer) #aiModelSettings, +:is(#altTextSettingsDialog #altTextSettingsContainer) + #showAltTextDialogDescription { + padding-inline-start: 40px; +} + +:is(#altTextSettingsDialog #altTextSettingsContainer) #automaticSettings { + display: flex; + flex-direction: column; + gap: 16px; +} + +.sidebar { + --csstools-light-dark-toggle--123: var(--csstools-color-scheme--light) #23222b; + --sidebar-bg-color: var(--csstools-light-dark-toggle--123, #fff); + --csstools-light-dark-toggle--124: var(--csstools-color-scheme--light) + rgb(251 251 254 / 0.1); + --sidebar-border-color: var( + --csstools-light-dark-toggle--124, + rgb(21 20 26 / 0.1) + ); + --csstools-light-dark-toggle--125: var(--csstools-color-scheme--light) + rgb(0 0 0 / 0.2); + --csstools-light-dark-toggle--126: var(--csstools-color-scheme--light) + rgb(0 0 0 / 0.4); + --sidebar-box-shadow: + 0 0.25px 0.75px var(--csstools-light-dark-toggle--125, rgb(0 0 0 / 0.05)), + 0 2px 6px 0 var(--csstools-light-dark-toggle--126, rgb(0 0 0 / 0.1)); + --sidebar-border-radius: 8px; + --sidebar-padding: 5px; + --sidebar-min-width: 180px; + --sidebar-max-width: 632px; + --sidebar-width: 239px; + --resizer-width: 4px; + --csstools-light-dark-toggle--127: var(--csstools-color-scheme--light) #00cadb; + --resizer-hover-bg-color: var(--csstools-light-dark-toggle--127, #0062fa); +} + +@supports (color: light-dark(red, red)) { + .sidebar { + --sidebar-bg-color: light-dark(#fff, #23222b); + } +} + +@supports (color: light-dark(red, red)) and (color: rgb(0 0 0 / 0)) { + .sidebar { + --sidebar-border-color: light-dark( + rgb(21 20 26 / 0.1), + rgb(251 251 254 / 0.1) + ); + --sidebar-box-shadow: + 0 0.25px 0.75px light-dark(rgb(0 0 0 / 0.05), rgb(0 0 0 / 0.2)), + 0 2px 6px 0 light-dark(rgb(0 0 0 / 0.1), rgb(0 0 0 / 0.4)); + } +} + +@supports (color: light-dark(red, red)) { + .sidebar { + --resizer-hover-bg-color: light-dark(#0062fa, #00cadb); + } +} + +@supports not (color: light-dark(tan, tan)) { + .sidebar * { + --csstools-light-dark-toggle--123: var(--csstools-color-scheme--light) + #23222b; + --sidebar-bg-color: var(--csstools-light-dark-toggle--123, #fff); + --csstools-light-dark-toggle--124: var(--csstools-color-scheme--light) + rgb(251 251 254 / 0.1); + --sidebar-border-color: var( + --csstools-light-dark-toggle--124, + rgb(21 20 26 / 0.1) + ); + --csstools-light-dark-toggle--125: var(--csstools-color-scheme--light) + rgb(0 0 0 / 0.2); + --csstools-light-dark-toggle--126: var(--csstools-color-scheme--light) + rgb(0 0 0 / 0.4); + --sidebar-box-shadow: + 0 0.25px 0.75px var(--csstools-light-dark-toggle--125, rgb(0 0 0 / 0.05)), + 0 2px 6px 0 var(--csstools-light-dark-toggle--126, rgb(0 0 0 / 0.1)); + --csstools-light-dark-toggle--127: var(--csstools-color-scheme--light) + #00cadb; + --resizer-hover-bg-color: var(--csstools-light-dark-toggle--127, #0062fa); + } +} + +@media screen and (forced-colors: active) { + .sidebar { + --sidebar-bg-color: Canvas; + --sidebar-border-color: CanvasText; + --sidebar-box-shadow: none; + --resizer-hover-bg-color: CanvasText; + } +} + +.sidebar { + border-radius: var(--sidebar-border-radius); + box-shadow: var(--sidebar-box-shadow); + border: 1px solid var(--sidebar-border-color); + background-color: var(--sidebar-bg-color); + inset-block-start: calc(100% + var(--doorhanger-height) - 2px); + padding-block: var(--sidebar-padding); + width: var(--sidebar-width); + min-width: var(--sidebar-min-width); + max-width: var(--sidebar-max-width); +} + +.sidebar .sidebarResizer { + width: var(--resizer-width); + background-color: transparent; + forced-color-adjust: none; + cursor: ew-resize; + position: absolute; + inset-block: calc(var(--sidebar-padding) + var(--sidebar-border-radius)); + inset-inline-start: calc(0px - var(--resizer-width) / 2); + transition: background-color 0.5s ease-in-out; + box-sizing: border-box; + border: 1px solid transparent; + border-block-width: 0; + background-clip: content-box; +} + +:is(.sidebar .sidebarResizer):hover { + background-color: var(--resizer-hover-bg-color); +} + +.sidebar.resizing { + cursor: ew-resize; + -webkit-user-select: none; + -moz-user-select: none; + user-select: none; +} + +.sidebar.resizing :not(.sidebarResizer) { + pointer-events: none; +} + +:root { + --csstools-color-scheme--light: initial; + color-scheme: light dark; + + --viewer-container-height: 0; + --pdfViewer-padding-bottom: 0; + --page-margin: 1px auto -8px; + --page-border: 9px solid transparent; + --spreadHorizontalWrapped-margin-LR: -3.5px; + --loading-icon-delay: 400ms; + --csstools-light-dark-toggle--128: var(--csstools-color-scheme--light) #0df; + --focus-ring-color: var(--csstools-light-dark-toggle--128, #0060df); + --focus-ring-outline: 2px solid var(--focus-ring-color); +} + +@supports (color: light-dark(red, red)) { + :root { + --focus-ring-color: light-dark(#0060df, #0df); + } +} + +@supports not (color: light-dark(tan, tan)) { + :root * { + --csstools-light-dark-toggle--128: var(--csstools-color-scheme--light) #0df; + --focus-ring-color: var(--csstools-light-dark-toggle--128, #0060df); + } +} + +@media (prefers-color-scheme: dark) { + :root { + --csstools-color-scheme--light: light; + } +} + +@media screen and (forced-colors: active) { + :root { + --pdfViewer-padding-bottom: 9px; + --page-margin: 8px auto -1px; + --page-border: 1px solid CanvasText; + --spreadHorizontalWrapped-margin-LR: 3.5px; + --focus-ring-color: CanvasText; + } +} + +[data-main-rotation='90'] { + transform: rotate(90deg) translateY(-100%); +} +[data-main-rotation='180'] { + transform: rotate(180deg) translate(-100%, -100%); +} +[data-main-rotation='270'] { + transform: rotate(270deg) translateX(-100%); } #hiddenCopyElement, -.hiddenCanvasElement{ - position:absolute; - top:0; - left:0; - width:0; - height:0; - display:none; +.hiddenCanvasElement { + position: absolute; + top: 0; + left: 0; + width: 0; + height: 0; + display: none; } -.pdfViewer{ - --scale-factor:1; - --page-bg-color:unset; +.pdfViewer { + --scale-factor: 1; + --page-bg-color: unset; - padding-bottom:var(--pdfViewer-padding-bottom); + padding-bottom: var(--pdfViewer-padding-bottom); - --hcm-highlight-filter:none; - --hcm-highlight-selected-filter:none; + --hcm-highlight-filter: none; + --hcm-highlight-selected-filter: none; } -@media screen and (forced-colors: active){ - -.pdfViewer{ - --hcm-highlight-filter:invert(100%); -} +@media screen and (forced-colors: active) { + .pdfViewer { + --hcm-highlight-filter: invert(100%); } - -.pdfViewer.copyAll{ - cursor:wait; - } - -.pdfViewer .canvasWrapper{ - overflow:hidden; - width:100%; - height:100%; - } - -:is(.pdfViewer .canvasWrapper) canvas{ - position:absolute; - top:0; - left:0; - margin:0; - display:block; - width:100%; - height:100%; - contain:content; - } - -:is(:is(.pdfViewer .canvasWrapper) canvas) .structTree{ - contain:strict; - } - -.detailView:is(:is(.pdfViewer .canvasWrapper) canvas){ - image-rendering:pixelated; - } - -.pdfViewer .page{ - --user-unit:1; - --total-scale-factor:calc(var(--scale-factor) * var(--user-unit)); - --scale-round-x:1px; - --scale-round-y:1px; - - direction:ltr; - width:816px; - height:1056px; - margin:var(--page-margin); - position:relative; - overflow:visible; - border:var(--page-border); - background-clip:content-box; - background-color:var(--page-bg-color, rgb(255 255 255)); } -.pdfViewer .dummyPage{ - position:relative; - width:0; - height:var(--viewer-container-height); +.pdfViewer.copyAll { + cursor: wait; } -.pdfViewer.noUserSelect{ - -webkit-user-select:none; - -moz-user-select:none; - user-select:none; +.pdfViewer .canvasWrapper { + overflow: hidden; + width: 100%; + height: 100%; } -.pdfViewer.removePageBorders .page{ - margin:0 auto 10px; - border:none; +:is(.pdfViewer .canvasWrapper) canvas { + position: absolute; + top: 0; + left: 0; + margin: 0; + display: block; + width: 100%; + height: 100%; + contain: content; +} + +:is(:is(.pdfViewer .canvasWrapper) canvas) .structTree { + contain: strict; +} + +.detailView:is(:is(.pdfViewer .canvasWrapper) canvas) { + image-rendering: pixelated; +} + +.pdfViewer .page { + --user-unit: 1; + --total-scale-factor: calc(var(--scale-factor) * var(--user-unit)); + --scale-round-x: 1px; + --scale-round-y: 1px; + + direction: ltr; + width: 816px; + height: 1056px; + margin: var(--page-margin); + position: relative; + overflow: visible; + border: var(--page-border); + background-clip: content-box; + background-color: var(--page-bg-color, rgb(255 255 255)); +} + +.pdfViewer .dummyPage { + position: relative; + width: 0; + height: var(--viewer-container-height); +} + +.pdfViewer.noUserSelect { + -webkit-user-select: none; + -moz-user-select: none; + user-select: none; +} + +.pdfViewer.removePageBorders .page { + margin: 0 auto 10px; + border: none; } .pdfViewer:is(.scrollHorizontal, .scrollWrapped), -.spread{ - margin-inline:3.5px; - text-align:center; +.spread { + margin-inline: 3.5px; + text-align: center; } .pdfViewer.scrollHorizontal, -.spread{ - white-space:nowrap; +.spread { + white-space: nowrap; } .pdfViewer.removePageBorders, -.pdfViewer:is(.scrollHorizontal, .scrollWrapped) .spread{ - margin-inline:0; +.pdfViewer:is(.scrollHorizontal, .scrollWrapped) .spread { + margin-inline: 0; } .spread :is(.page, .dummyPage), -.pdfViewer:is(.scrollHorizontal, .scrollWrapped) :is(.page, .spread){ - display:inline-block; - vertical-align:middle; +.pdfViewer:is(.scrollHorizontal, .scrollWrapped) :is(.page, .spread) { + display: inline-block; + vertical-align: middle; } .spread .page, -.pdfViewer:is(.scrollHorizontal, .scrollWrapped) .page{ - margin-inline:var(--spreadHorizontalWrapped-margin-LR); +.pdfViewer:is(.scrollHorizontal, .scrollWrapped) .page { + margin-inline: var(--spreadHorizontalWrapped-margin-LR); } .pdfViewer.removePageBorders .spread .page, -.pdfViewer.removePageBorders:is(.scrollHorizontal, .scrollWrapped) .page{ - margin-inline:5px; +.pdfViewer.removePageBorders:is(.scrollHorizontal, .scrollWrapped) .page { + margin-inline: 5px; } -.pdfViewer .page.loadingIcon::after{ - position:absolute; - top:0; - left:0; - content:""; - width:100%; - height:100%; - background:url("images/loading-icon.gif") center no-repeat; - display:none; - transition-property:display; - transition-delay:var(--loading-icon-delay); - z-index:5; - contain:strict; +.pdfViewer .page.loadingIcon::after { + position: absolute; + top: 0; + left: 0; + content: ''; + width: 100%; + height: 100%; + background: url('images/loading-icon.gif') center no-repeat; + display: none; + transition-property: display; + transition-delay: var(--loading-icon-delay); + z-index: 5; + contain: strict; } -.pdfViewer .page.loading::after{ - display:block; +.pdfViewer .page.loading::after { + display: block; } -.pdfViewer .page:not(.loading)::after{ - transition-property:none; - display:none; +.pdfViewer .page:not(.loading)::after { + transition-property: none; + display: none; } -.pdfPresentationMode .pdfViewer{ - padding-bottom:0; +.pdfPresentationMode .pdfViewer { + padding-bottom: 0; } -.pdfPresentationMode .spread{ - margin:0; +.pdfPresentationMode .spread { + margin: 0; } -.pdfPresentationMode .pdfViewer .page{ - margin:0 auto; - border:2px solid transparent; +.pdfPresentationMode .pdfViewer .page { + margin: 0 auto; + border: 2px solid transparent; } -:root{ - --dir-factor:1; - --inline-start:left; - --inline-end:right; +:root { + --dir-factor: 1; + --inline-start: left; + --inline-end: right; - --sidebar-width:200px; - --sidebar-transition-duration:200ms; - --sidebar-transition-timing-function:ease; + --sidebar-width: 200px; + --sidebar-transition-duration: 200ms; + --sidebar-transition-timing-function: ease; - --toolbar-height:32px; - --toolbar-horizontal-padding:1px; - --toolbar-vertical-padding:2px; - --icon-size:16px; + --toolbar-height: 32px; + --toolbar-horizontal-padding: 1px; + --toolbar-vertical-padding: 2px; + --icon-size: 16px; - --toolbar-icon-opacity:0.7; - --doorhanger-icon-opacity:0.9; - --doorhanger-height:8px; + --toolbar-icon-opacity: 0.7; + --doorhanger-icon-opacity: 0.9; + --doorhanger-height: 8px; - --csstools-light-dark-toggle--0:var(--csstools-color-scheme--light) rgb(249 249 250); + --csstools-light-dark-toggle--0: var(--csstools-color-scheme--light) + rgb(249 249 250); - --main-color:var(--csstools-light-dark-toggle--0, rgb(12 12 13)); - --csstools-light-dark-toggle--1:var(--csstools-color-scheme--light) rgb(42 42 46); - --body-bg-color:var(--csstools-light-dark-toggle--1, rgb(212 212 215)); - --csstools-light-dark-toggle--2:var(--csstools-color-scheme--light) rgb(0 96 223); - --progressBar-color:var(--csstools-light-dark-toggle--2, rgb(10 132 255)); - --csstools-light-dark-toggle--3:var(--csstools-color-scheme--light) rgb(40 40 43); - --progressBar-bg-color:var(--csstools-light-dark-toggle--3, rgb(221 221 222)); - --csstools-light-dark-toggle--4:var(--csstools-color-scheme--light) rgb(20 68 133); - --progressBar-blend-color:var(--csstools-light-dark-toggle--4, rgb(116 177 239)); - --csstools-light-dark-toggle--5:var(--csstools-color-scheme--light) rgb(121 121 123); - --scrollbar-color:var(--csstools-light-dark-toggle--5, auto); - --csstools-light-dark-toggle--6:var(--csstools-color-scheme--light) rgb(35 35 39); - --scrollbar-bg-color:var(--csstools-light-dark-toggle--6, auto); - --csstools-light-dark-toggle--7:var(--csstools-color-scheme--light) rgb(255 255 255); - --toolbar-icon-bg-color:var(--csstools-light-dark-toggle--7, rgb(0 0 0)); - --csstools-light-dark-toggle--8:var(--csstools-color-scheme--light) rgb(255 255 255); - --toolbar-icon-hover-bg-color:var(--csstools-light-dark-toggle--8, rgb(0 0 0)); + --main-color: var(--csstools-light-dark-toggle--0, rgb(12 12 13)); + --csstools-light-dark-toggle--1: var(--csstools-color-scheme--light) + rgb(42 42 46); + --body-bg-color: var(--csstools-light-dark-toggle--1, rgb(212 212 215)); + --csstools-light-dark-toggle--2: var(--csstools-color-scheme--light) + rgb(0 96 223); + --progressBar-color: var(--csstools-light-dark-toggle--2, rgb(10 132 255)); + --csstools-light-dark-toggle--3: var(--csstools-color-scheme--light) + rgb(40 40 43); + --progressBar-bg-color: var( + --csstools-light-dark-toggle--3, + rgb(221 221 222) + ); + --csstools-light-dark-toggle--4: var(--csstools-color-scheme--light) + rgb(20 68 133); + --progressBar-blend-color: var( + --csstools-light-dark-toggle--4, + rgb(116 177 239) + ); + --csstools-light-dark-toggle--5: var(--csstools-color-scheme--light) + rgb(121 121 123); + --scrollbar-color: var(--csstools-light-dark-toggle--5, auto); + --csstools-light-dark-toggle--6: var(--csstools-color-scheme--light) + rgb(35 35 39); + --scrollbar-bg-color: var(--csstools-light-dark-toggle--6, auto); + --csstools-light-dark-toggle--7: var(--csstools-color-scheme--light) + rgb(255 255 255); + --toolbar-icon-bg-color: var(--csstools-light-dark-toggle--7, rgb(0 0 0)); + --csstools-light-dark-toggle--8: var(--csstools-color-scheme--light) + rgb(255 255 255); + --toolbar-icon-hover-bg-color: var( + --csstools-light-dark-toggle--8, + rgb(0 0 0) + ); - --csstools-light-dark-toggle--9:var(--csstools-color-scheme--light) rgb(42 42 46 / 0.9); + --csstools-light-dark-toggle--9: var(--csstools-color-scheme--light) + rgb(42 42 46 / 0.9); - --sidebar-narrow-bg-color:var(--csstools-light-dark-toggle--9, rgb(212 212 215 / 0.9)); - --csstools-light-dark-toggle--10:var(--csstools-color-scheme--light) rgb(50 50 52); - --sidebar-toolbar-bg-color:var(--csstools-light-dark-toggle--10, rgb(245 246 247)); - --csstools-light-dark-toggle--11:var(--csstools-color-scheme--light) rgb(56 56 61); - --toolbar-bg-color:var(--csstools-light-dark-toggle--11, rgb(249 249 250)); - --csstools-light-dark-toggle--12:var(--csstools-color-scheme--light) rgb(12 12 13); - --toolbar-border-color:var(--csstools-light-dark-toggle--12, rgb(184 184 184)); - --toolbar-box-shadow:0 1px 0 var(--toolbar-border-color); - --toolbar-border-bottom:none; - --toolbarSidebar-box-shadow:inset calc(-1px * var(--dir-factor)) 0 0 rgb(0 0 0 / 0.25), 0 1px 0 rgb(0 0 0 / 0.15), 0 0 1px rgb(0 0 0 / 0.1); - --toolbarSidebar-border-bottom:none; - --button-hover-color:color-mix(in srgb, currentColor 17%, transparent); - --csstools-light-dark-toggle--13:var(--csstools-color-scheme--light) rgb(255 255 255); - --toggled-btn-color:var(--csstools-light-dark-toggle--13, rgb(0 0 0)); - --toggled-btn-bg-color:rgb(0 0 0 / 0.3); - --toggled-hover-active-btn-color:rgb(0 0 0 / 0.4); - --toggled-hover-btn-outline:none; - --csstools-light-dark-toggle--14:var(--csstools-color-scheme--light) rgb(74 74 79); - --dropdown-btn-bg-color:var(--csstools-light-dark-toggle--14, rgb(215 215 219)); - --dropdown-btn-border:none; - --separator-color:rgb(0 0 0 / 0.3); - --csstools-light-dark-toggle--15:var(--csstools-color-scheme--light) rgb(250 250 250); - --field-color:var(--csstools-light-dark-toggle--15, rgb(6 6 6)); - --csstools-light-dark-toggle--16:var(--csstools-color-scheme--light) rgb(64 64 68); - --field-bg-color:var(--csstools-light-dark-toggle--16, rgb(255 255 255)); - --csstools-light-dark-toggle--17:var(--csstools-color-scheme--light) rgb(115 115 115); - --field-border-color:var(--csstools-light-dark-toggle--17, rgb(187 187 188)); - --csstools-light-dark-toggle--18:var(--csstools-color-scheme--light) rgb(255 255 255 / 0.8); - --treeitem-color:var(--csstools-light-dark-toggle--18, rgb(0 0 0 / 0.8)); - --csstools-light-dark-toggle--19:var(--csstools-color-scheme--light) rgb(255 255 255 / 0.15); - --treeitem-bg-color:var(--csstools-light-dark-toggle--19, rgb(0 0 0 / 0.15)); - --csstools-light-dark-toggle--20:var(--csstools-color-scheme--light) rgb(255 255 255 / 0.9); - --treeitem-hover-color:var(--csstools-light-dark-toggle--20, rgb(0 0 0 / 0.9)); - --csstools-light-dark-toggle--21:var(--csstools-color-scheme--light) rgb(255 255 255 / 0.9); - --treeitem-selected-color:var(--csstools-light-dark-toggle--21, rgb(0 0 0 / 0.9)); - --csstools-light-dark-toggle--22:var(--csstools-color-scheme--light) rgb(255 255 255 / 0.25); - --treeitem-selected-bg-color:var(--csstools-light-dark-toggle--22, rgb(0 0 0 / 0.25)); - --csstools-light-dark-toggle--23:var(--csstools-color-scheme--light) rgb(255 255 255 / 0.1); - --thumbnail-hover-color:var(--csstools-light-dark-toggle--23, rgb(0 0 0 / 0.1)); - --csstools-light-dark-toggle--24:var(--csstools-color-scheme--light) rgb(255 255 255 / 0.2); - --thumbnail-selected-color:var(--csstools-light-dark-toggle--24, rgb(0 0 0 / 0.2)); - --csstools-light-dark-toggle--25:var(--csstools-color-scheme--light) #42414d; - --doorhanger-bg-color:var(--csstools-light-dark-toggle--25, rgb(255 255 255)); - --csstools-light-dark-toggle--26:var(--csstools-color-scheme--light) rgb(39 39 43); - --doorhanger-border-color:var(--csstools-light-dark-toggle--26, rgb(12 12 13 / 0.2)); - --csstools-light-dark-toggle--27:var(--csstools-color-scheme--light) rgb(249 249 250); - --doorhanger-hover-color:var(--csstools-light-dark-toggle--27, rgb(12 12 13)); - --csstools-light-dark-toggle--28:var(--csstools-color-scheme--light) rgb(92 92 97); - --doorhanger-separator-color:var(--csstools-light-dark-toggle--28, rgb(222 222 222)); - --dialog-button-border:none; - --csstools-light-dark-toggle--29:var(--csstools-color-scheme--light) rgb(92 92 97); - --dialog-button-bg-color:var(--csstools-light-dark-toggle--29, rgb(12 12 13 / 0.1)); - --csstools-light-dark-toggle--30:var(--csstools-color-scheme--light) rgb(115 115 115); - --dialog-button-hover-bg-color:var(--csstools-light-dark-toggle--30, rgb(12 12 13 / 0.3)); + --sidebar-narrow-bg-color: var( + --csstools-light-dark-toggle--9, + rgb(212 212 215 / 0.9) + ); + --csstools-light-dark-toggle--10: var(--csstools-color-scheme--light) + rgb(50 50 52); + --sidebar-toolbar-bg-color: var( + --csstools-light-dark-toggle--10, + rgb(245 246 247) + ); + --csstools-light-dark-toggle--11: var(--csstools-color-scheme--light) + rgb(56 56 61); + --toolbar-bg-color: var(--csstools-light-dark-toggle--11, rgb(249 249 250)); + --csstools-light-dark-toggle--12: var(--csstools-color-scheme--light) + rgb(12 12 13); + --toolbar-border-color: var( + --csstools-light-dark-toggle--12, + rgb(184 184 184) + ); + --toolbar-box-shadow: 0 1px 0 var(--toolbar-border-color); + --toolbar-border-bottom: none; + --toolbarSidebar-box-shadow: + inset calc(-1px * var(--dir-factor)) 0 0 rgb(0 0 0 / 0.25), + 0 1px 0 rgb(0 0 0 / 0.15), 0 0 1px rgb(0 0 0 / 0.1); + --toolbarSidebar-border-bottom: none; + --button-hover-color: color-mix(in srgb, currentColor 17%, transparent); + --csstools-light-dark-toggle--13: var(--csstools-color-scheme--light) + rgb(255 255 255); + --toggled-btn-color: var(--csstools-light-dark-toggle--13, rgb(0 0 0)); + --toggled-btn-bg-color: rgb(0 0 0 / 0.3); + --toggled-hover-active-btn-color: rgb(0 0 0 / 0.4); + --toggled-hover-btn-outline: none; + --csstools-light-dark-toggle--14: var(--csstools-color-scheme--light) + rgb(74 74 79); + --dropdown-btn-bg-color: var( + --csstools-light-dark-toggle--14, + rgb(215 215 219) + ); + --dropdown-btn-border: none; + --separator-color: rgb(0 0 0 / 0.3); + --csstools-light-dark-toggle--15: var(--csstools-color-scheme--light) + rgb(250 250 250); + --field-color: var(--csstools-light-dark-toggle--15, rgb(6 6 6)); + --csstools-light-dark-toggle--16: var(--csstools-color-scheme--light) + rgb(64 64 68); + --field-bg-color: var(--csstools-light-dark-toggle--16, rgb(255 255 255)); + --csstools-light-dark-toggle--17: var(--csstools-color-scheme--light) + rgb(115 115 115); + --field-border-color: var(--csstools-light-dark-toggle--17, rgb(187 187 188)); + --csstools-light-dark-toggle--18: var(--csstools-color-scheme--light) + rgb(255 255 255 / 0.8); + --treeitem-color: var(--csstools-light-dark-toggle--18, rgb(0 0 0 / 0.8)); + --csstools-light-dark-toggle--19: var(--csstools-color-scheme--light) + rgb(255 255 255 / 0.15); + --treeitem-bg-color: var(--csstools-light-dark-toggle--19, rgb(0 0 0 / 0.15)); + --csstools-light-dark-toggle--20: var(--csstools-color-scheme--light) + rgb(255 255 255 / 0.9); + --treeitem-hover-color: var( + --csstools-light-dark-toggle--20, + rgb(0 0 0 / 0.9) + ); + --csstools-light-dark-toggle--21: var(--csstools-color-scheme--light) + rgb(255 255 255 / 0.9); + --treeitem-selected-color: var( + --csstools-light-dark-toggle--21, + rgb(0 0 0 / 0.9) + ); + --csstools-light-dark-toggle--22: var(--csstools-color-scheme--light) + rgb(255 255 255 / 0.25); + --treeitem-selected-bg-color: var( + --csstools-light-dark-toggle--22, + rgb(0 0 0 / 0.25) + ); + --csstools-light-dark-toggle--23: var(--csstools-color-scheme--light) + rgb(255 255 255 / 0.1); + --thumbnail-hover-color: var( + --csstools-light-dark-toggle--23, + rgb(0 0 0 / 0.1) + ); + --csstools-light-dark-toggle--24: var(--csstools-color-scheme--light) + rgb(255 255 255 / 0.2); + --thumbnail-selected-color: var( + --csstools-light-dark-toggle--24, + rgb(0 0 0 / 0.2) + ); + --csstools-light-dark-toggle--25: var(--csstools-color-scheme--light) #42414d; + --doorhanger-bg-color: var( + --csstools-light-dark-toggle--25, + rgb(255 255 255) + ); + --csstools-light-dark-toggle--26: var(--csstools-color-scheme--light) + rgb(39 39 43); + --doorhanger-border-color: var( + --csstools-light-dark-toggle--26, + rgb(12 12 13 / 0.2) + ); + --csstools-light-dark-toggle--27: var(--csstools-color-scheme--light) + rgb(249 249 250); + --doorhanger-hover-color: var( + --csstools-light-dark-toggle--27, + rgb(12 12 13) + ); + --csstools-light-dark-toggle--28: var(--csstools-color-scheme--light) + rgb(92 92 97); + --doorhanger-separator-color: var( + --csstools-light-dark-toggle--28, + rgb(222 222 222) + ); + --dialog-button-border: none; + --csstools-light-dark-toggle--29: var(--csstools-color-scheme--light) + rgb(92 92 97); + --dialog-button-bg-color: var( + --csstools-light-dark-toggle--29, + rgb(12 12 13 / 0.1) + ); + --csstools-light-dark-toggle--30: var(--csstools-color-scheme--light) + rgb(115 115 115); + --dialog-button-hover-bg-color: var( + --csstools-light-dark-toggle--30, + rgb(12 12 13 / 0.3) + ); - --loading-icon:url(images/loading.svg); - --treeitem-expanded-icon:url(images/treeitem-expanded.svg); - --treeitem-collapsed-icon:url(images/treeitem-collapsed.svg); - --toolbarButton-editorComment-icon:url(images/comment-editButton.svg); - --toolbarButton-editorFreeText-icon:url(images/toolbarButton-editorFreeText.svg); - --toolbarButton-editorHighlight-icon:url(images/toolbarButton-editorHighlight.svg); - --toolbarButton-editorInk-icon:url(images/toolbarButton-editorInk.svg); - --toolbarButton-editorStamp-icon:url(images/toolbarButton-editorStamp.svg); - --toolbarButton-editorSignature-icon:url(images/toolbarButton-editorSignature.svg); - --toolbarButton-menuArrow-icon:url(images/toolbarButton-menuArrow.svg); - --toolbarButton-sidebarToggle-icon:url(images/toolbarButton-sidebarToggle.svg); - --toolbarButton-secondaryToolbarToggle-icon:url(images/toolbarButton-secondaryToolbarToggle.svg); - --toolbarButton-pageUp-icon:url(images/toolbarButton-pageUp.svg); - --toolbarButton-pageDown-icon:url(images/toolbarButton-pageDown.svg); - --toolbarButton-zoomOut-icon:url(images/toolbarButton-zoomOut.svg); - --toolbarButton-zoomIn-icon:url(images/toolbarButton-zoomIn.svg); - --toolbarButton-presentationMode-icon:url(images/toolbarButton-presentationMode.svg); - --toolbarButton-print-icon:url(images/toolbarButton-print.svg); - --toolbarButton-openFile-icon:url(images/toolbarButton-openFile.svg); - --toolbarButton-download-icon:url(images/toolbarButton-download.svg); - --toolbarButton-bookmark-icon:url(images/toolbarButton-bookmark.svg); - --toolbarButton-viewThumbnail-icon:url(images/toolbarButton-viewThumbnail.svg); - --toolbarButton-viewOutline-icon:url(images/toolbarButton-viewOutline.svg); - --toolbarButton-viewAttachments-icon:url(images/toolbarButton-viewAttachments.svg); - --toolbarButton-viewLayers-icon:url(images/toolbarButton-viewLayers.svg); - --toolbarButton-currentOutlineItem-icon:url(images/toolbarButton-currentOutlineItem.svg); - --toolbarButton-search-icon:url(images/toolbarButton-search.svg); - --findbarButton-previous-icon:url(images/findbarButton-previous.svg); - --findbarButton-next-icon:url(images/findbarButton-next.svg); - --secondaryToolbarButton-firstPage-icon:url(images/secondaryToolbarButton-firstPage.svg); - --secondaryToolbarButton-lastPage-icon:url(images/secondaryToolbarButton-lastPage.svg); - --secondaryToolbarButton-rotateCcw-icon:url(images/secondaryToolbarButton-rotateCcw.svg); - --secondaryToolbarButton-rotateCw-icon:url(images/secondaryToolbarButton-rotateCw.svg); - --secondaryToolbarButton-selectTool-icon:url(images/secondaryToolbarButton-selectTool.svg); - --secondaryToolbarButton-handTool-icon:url(images/secondaryToolbarButton-handTool.svg); - --secondaryToolbarButton-scrollPage-icon:url(images/secondaryToolbarButton-scrollPage.svg); - --secondaryToolbarButton-scrollVertical-icon:url(images/secondaryToolbarButton-scrollVertical.svg); - --secondaryToolbarButton-scrollHorizontal-icon:url(images/secondaryToolbarButton-scrollHorizontal.svg); - --secondaryToolbarButton-scrollWrapped-icon:url(images/secondaryToolbarButton-scrollWrapped.svg); - --secondaryToolbarButton-spreadNone-icon:url(images/secondaryToolbarButton-spreadNone.svg); - --secondaryToolbarButton-spreadOdd-icon:url(images/secondaryToolbarButton-spreadOdd.svg); - --secondaryToolbarButton-spreadEven-icon:url(images/secondaryToolbarButton-spreadEven.svg); - --secondaryToolbarButton-imageAltTextSettings-icon:var( + --loading-icon: url(images/loading.svg); + --treeitem-expanded-icon: url(images/treeitem-expanded.svg); + --treeitem-collapsed-icon: url(images/treeitem-collapsed.svg); + --toolbarButton-editorComment-icon: url(images/comment-editButton.svg); + --toolbarButton-editorFreeText-icon: url(images/toolbarButton-editorFreeText.svg); + --toolbarButton-editorHighlight-icon: url(images/toolbarButton-editorHighlight.svg); + --toolbarButton-editorInk-icon: url(images/toolbarButton-editorInk.svg); + --toolbarButton-editorStamp-icon: url(images/toolbarButton-editorStamp.svg); + --toolbarButton-editorSignature-icon: url(images/toolbarButton-editorSignature.svg); + --toolbarButton-menuArrow-icon: url(images/toolbarButton-menuArrow.svg); + --toolbarButton-sidebarToggle-icon: url(images/toolbarButton-sidebarToggle.svg); + --toolbarButton-secondaryToolbarToggle-icon: url(images/toolbarButton-secondaryToolbarToggle.svg); + --toolbarButton-pageUp-icon: url(images/toolbarButton-pageUp.svg); + --toolbarButton-pageDown-icon: url(images/toolbarButton-pageDown.svg); + --toolbarButton-zoomOut-icon: url(images/toolbarButton-zoomOut.svg); + --toolbarButton-zoomIn-icon: url(images/toolbarButton-zoomIn.svg); + --toolbarButton-presentationMode-icon: url(images/toolbarButton-presentationMode.svg); + --toolbarButton-print-icon: url(images/toolbarButton-print.svg); + --toolbarButton-openFile-icon: url(images/toolbarButton-openFile.svg); + --toolbarButton-download-icon: url(images/toolbarButton-download.svg); + --toolbarButton-bookmark-icon: url(images/toolbarButton-bookmark.svg); + --toolbarButton-viewThumbnail-icon: url(images/toolbarButton-viewThumbnail.svg); + --toolbarButton-viewOutline-icon: url(images/toolbarButton-viewOutline.svg); + --toolbarButton-viewAttachments-icon: url(images/toolbarButton-viewAttachments.svg); + --toolbarButton-viewLayers-icon: url(images/toolbarButton-viewLayers.svg); + --toolbarButton-currentOutlineItem-icon: url(images/toolbarButton-currentOutlineItem.svg); + --toolbarButton-search-icon: url(images/toolbarButton-search.svg); + --findbarButton-previous-icon: url(images/findbarButton-previous.svg); + --findbarButton-next-icon: url(images/findbarButton-next.svg); + --secondaryToolbarButton-firstPage-icon: url(images/secondaryToolbarButton-firstPage.svg); + --secondaryToolbarButton-lastPage-icon: url(images/secondaryToolbarButton-lastPage.svg); + --secondaryToolbarButton-rotateCcw-icon: url(images/secondaryToolbarButton-rotateCcw.svg); + --secondaryToolbarButton-rotateCw-icon: url(images/secondaryToolbarButton-rotateCw.svg); + --secondaryToolbarButton-selectTool-icon: url(images/secondaryToolbarButton-selectTool.svg); + --secondaryToolbarButton-handTool-icon: url(images/secondaryToolbarButton-handTool.svg); + --secondaryToolbarButton-scrollPage-icon: url(images/secondaryToolbarButton-scrollPage.svg); + --secondaryToolbarButton-scrollVertical-icon: url(images/secondaryToolbarButton-scrollVertical.svg); + --secondaryToolbarButton-scrollHorizontal-icon: url(images/secondaryToolbarButton-scrollHorizontal.svg); + --secondaryToolbarButton-scrollWrapped-icon: url(images/secondaryToolbarButton-scrollWrapped.svg); + --secondaryToolbarButton-spreadNone-icon: url(images/secondaryToolbarButton-spreadNone.svg); + --secondaryToolbarButton-spreadOdd-icon: url(images/secondaryToolbarButton-spreadOdd.svg); + --secondaryToolbarButton-spreadEven-icon: url(images/secondaryToolbarButton-spreadEven.svg); + --secondaryToolbarButton-imageAltTextSettings-icon: var( --toolbarButton-editorStamp-icon ); - --secondaryToolbarButton-documentProperties-icon:url(images/secondaryToolbarButton-documentProperties.svg); - --editorParams-stampAddImage-icon:url(images/toolbarButton-zoomIn.svg); - --comment-edit-button-icon:url(images/comment-editButton.svg); + --secondaryToolbarButton-documentProperties-icon: url(images/secondaryToolbarButton-documentProperties.svg); + --editorParams-stampAddImage-icon: url(images/toolbarButton-zoomIn.svg); + --comment-edit-button-icon: url(images/comment-editButton.svg); } -@supports (color: light-dark(red, red)) and (color: rgb(0 0 0 / 0)){ -:root{ +@supports (color: light-dark(red, red)) and (color: rgb(0 0 0 / 0)) { + :root { + --main-color: light-dark(rgb(12 12 13), rgb(249 249 250)); + --body-bg-color: light-dark(rgb(212 212 215), rgb(42 42 46)); + --progressBar-color: light-dark(rgb(10 132 255), rgb(0 96 223)); + --progressBar-bg-color: light-dark(rgb(221 221 222), rgb(40 40 43)); + --progressBar-blend-color: light-dark(rgb(116 177 239), rgb(20 68 133)); + --scrollbar-color: light-dark(auto, rgb(121 121 123)); + --scrollbar-bg-color: light-dark(auto, rgb(35 35 39)); + --toolbar-icon-bg-color: light-dark(rgb(0 0 0), rgb(255 255 255)); + --toolbar-icon-hover-bg-color: light-dark(rgb(0 0 0), rgb(255 255 255)); - --main-color:light-dark(rgb(12 12 13), rgb(249 249 250)); - --body-bg-color:light-dark(rgb(212 212 215), rgb(42 42 46)); - --progressBar-color:light-dark(rgb(10 132 255), rgb(0 96 223)); - --progressBar-bg-color:light-dark(rgb(221 221 222), rgb(40 40 43)); - --progressBar-blend-color:light-dark(rgb(116 177 239), rgb(20 68 133)); - --scrollbar-color:light-dark(auto, rgb(121 121 123)); - --scrollbar-bg-color:light-dark(auto, rgb(35 35 39)); - --toolbar-icon-bg-color:light-dark(rgb(0 0 0), rgb(255 255 255)); - --toolbar-icon-hover-bg-color:light-dark(rgb(0 0 0), rgb(255 255 255)); - - --sidebar-narrow-bg-color:light-dark( - rgb(212 212 215 / 0.9), - rgb(42 42 46 / 0.9) - ); - --sidebar-toolbar-bg-color:light-dark(rgb(245 246 247), rgb(50 50 52)); - --toolbar-bg-color:light-dark(rgb(249 249 250), rgb(56 56 61)); - --toolbar-border-color:light-dark(rgb(184 184 184), rgb(12 12 13)); - --toggled-btn-color:light-dark(rgb(0 0 0), rgb(255 255 255)); - --dropdown-btn-bg-color:light-dark(rgb(215 215 219), rgb(74 74 79)); - --field-color:light-dark(rgb(6 6 6), rgb(250 250 250)); - --field-bg-color:light-dark(rgb(255 255 255), rgb(64 64 68)); - --field-border-color:light-dark(rgb(187 187 188), rgb(115 115 115)); - --treeitem-color:light-dark(rgb(0 0 0 / 0.8), rgb(255 255 255 / 0.8)); - --treeitem-bg-color:light-dark(rgb(0 0 0 / 0.15), rgb(255 255 255 / 0.15)); - --treeitem-hover-color:light-dark(rgb(0 0 0 / 0.9), rgb(255 255 255 / 0.9)); - --treeitem-selected-color:light-dark( - rgb(0 0 0 / 0.9), - rgb(255 255 255 / 0.9) - ); - --treeitem-selected-bg-color:light-dark( - rgb(0 0 0 / 0.25), - rgb(255 255 255 / 0.25) - ); - --thumbnail-hover-color:light-dark(rgb(0 0 0 / 0.1), rgb(255 255 255 / 0.1)); - --thumbnail-selected-color:light-dark( - rgb(0 0 0 / 0.2), - rgb(255 255 255 / 0.2) - ); - --doorhanger-bg-color:light-dark(rgb(255 255 255), #42414d); - --doorhanger-border-color:light-dark(rgb(12 12 13 / 0.2), rgb(39 39 43)); - --doorhanger-hover-color:light-dark(rgb(12 12 13), rgb(249 249 250)); - --doorhanger-separator-color:light-dark(rgb(222 222 222), rgb(92 92 97)); - --dialog-button-bg-color:light-dark(rgb(12 12 13 / 0.1), rgb(92 92 97)); - --dialog-button-hover-bg-color:light-dark( - rgb(12 12 13 / 0.3), - rgb(115 115 115) - ); -} -} - -@supports not (color: light-dark(tan, tan)){ - -:root *{ - - --csstools-light-dark-toggle--0:var(--csstools-color-scheme--light) rgb(249 249 250); - - --main-color:var(--csstools-light-dark-toggle--0, rgb(12 12 13)); - --csstools-light-dark-toggle--1:var(--csstools-color-scheme--light) rgb(42 42 46); - --body-bg-color:var(--csstools-light-dark-toggle--1, rgb(212 212 215)); - --csstools-light-dark-toggle--2:var(--csstools-color-scheme--light) rgb(0 96 223); - --progressBar-color:var(--csstools-light-dark-toggle--2, rgb(10 132 255)); - --csstools-light-dark-toggle--3:var(--csstools-color-scheme--light) rgb(40 40 43); - --progressBar-bg-color:var(--csstools-light-dark-toggle--3, rgb(221 221 222)); - --csstools-light-dark-toggle--4:var(--csstools-color-scheme--light) rgb(20 68 133); - --progressBar-blend-color:var(--csstools-light-dark-toggle--4, rgb(116 177 239)); - --csstools-light-dark-toggle--5:var(--csstools-color-scheme--light) rgb(121 121 123); - --scrollbar-color:var(--csstools-light-dark-toggle--5, auto); - --csstools-light-dark-toggle--6:var(--csstools-color-scheme--light) rgb(35 35 39); - --scrollbar-bg-color:var(--csstools-light-dark-toggle--6, auto); - --csstools-light-dark-toggle--7:var(--csstools-color-scheme--light) rgb(255 255 255); - --toolbar-icon-bg-color:var(--csstools-light-dark-toggle--7, rgb(0 0 0)); - --csstools-light-dark-toggle--8:var(--csstools-color-scheme--light) rgb(255 255 255); - --toolbar-icon-hover-bg-color:var(--csstools-light-dark-toggle--8, rgb(0 0 0)); - - --csstools-light-dark-toggle--9:var(--csstools-color-scheme--light) rgb(42 42 46 / 0.9); - - --sidebar-narrow-bg-color:var(--csstools-light-dark-toggle--9, rgb(212 212 215 / 0.9)); - --csstools-light-dark-toggle--10:var(--csstools-color-scheme--light) rgb(50 50 52); - --sidebar-toolbar-bg-color:var(--csstools-light-dark-toggle--10, rgb(245 246 247)); - --csstools-light-dark-toggle--11:var(--csstools-color-scheme--light) rgb(56 56 61); - --toolbar-bg-color:var(--csstools-light-dark-toggle--11, rgb(249 249 250)); - --csstools-light-dark-toggle--12:var(--csstools-color-scheme--light) rgb(12 12 13); - --toolbar-border-color:var(--csstools-light-dark-toggle--12, rgb(184 184 184)); - --csstools-light-dark-toggle--13:var(--csstools-color-scheme--light) rgb(255 255 255); - --toggled-btn-color:var(--csstools-light-dark-toggle--13, rgb(0 0 0)); - --csstools-light-dark-toggle--14:var(--csstools-color-scheme--light) rgb(74 74 79); - --dropdown-btn-bg-color:var(--csstools-light-dark-toggle--14, rgb(215 215 219)); - --csstools-light-dark-toggle--15:var(--csstools-color-scheme--light) rgb(250 250 250); - --field-color:var(--csstools-light-dark-toggle--15, rgb(6 6 6)); - --csstools-light-dark-toggle--16:var(--csstools-color-scheme--light) rgb(64 64 68); - --field-bg-color:var(--csstools-light-dark-toggle--16, rgb(255 255 255)); - --csstools-light-dark-toggle--17:var(--csstools-color-scheme--light) rgb(115 115 115); - --field-border-color:var(--csstools-light-dark-toggle--17, rgb(187 187 188)); - --csstools-light-dark-toggle--18:var(--csstools-color-scheme--light) rgb(255 255 255 / 0.8); - --treeitem-color:var(--csstools-light-dark-toggle--18, rgb(0 0 0 / 0.8)); - --csstools-light-dark-toggle--19:var(--csstools-color-scheme--light) rgb(255 255 255 / 0.15); - --treeitem-bg-color:var(--csstools-light-dark-toggle--19, rgb(0 0 0 / 0.15)); - --csstools-light-dark-toggle--20:var(--csstools-color-scheme--light) rgb(255 255 255 / 0.9); - --treeitem-hover-color:var(--csstools-light-dark-toggle--20, rgb(0 0 0 / 0.9)); - --csstools-light-dark-toggle--21:var(--csstools-color-scheme--light) rgb(255 255 255 / 0.9); - --treeitem-selected-color:var(--csstools-light-dark-toggle--21, rgb(0 0 0 / 0.9)); - --csstools-light-dark-toggle--22:var(--csstools-color-scheme--light) rgb(255 255 255 / 0.25); - --treeitem-selected-bg-color:var(--csstools-light-dark-toggle--22, rgb(0 0 0 / 0.25)); - --csstools-light-dark-toggle--23:var(--csstools-color-scheme--light) rgb(255 255 255 / 0.1); - --thumbnail-hover-color:var(--csstools-light-dark-toggle--23, rgb(0 0 0 / 0.1)); - --csstools-light-dark-toggle--24:var(--csstools-color-scheme--light) rgb(255 255 255 / 0.2); - --thumbnail-selected-color:var(--csstools-light-dark-toggle--24, rgb(0 0 0 / 0.2)); - --csstools-light-dark-toggle--25:var(--csstools-color-scheme--light) #42414d; - --doorhanger-bg-color:var(--csstools-light-dark-toggle--25, rgb(255 255 255)); - --csstools-light-dark-toggle--26:var(--csstools-color-scheme--light) rgb(39 39 43); - --doorhanger-border-color:var(--csstools-light-dark-toggle--26, rgb(12 12 13 / 0.2)); - --csstools-light-dark-toggle--27:var(--csstools-color-scheme--light) rgb(249 249 250); - --doorhanger-hover-color:var(--csstools-light-dark-toggle--27, rgb(12 12 13)); - --csstools-light-dark-toggle--28:var(--csstools-color-scheme--light) rgb(92 92 97); - --doorhanger-separator-color:var(--csstools-light-dark-toggle--28, rgb(222 222 222)); - --csstools-light-dark-toggle--29:var(--csstools-color-scheme--light) rgb(92 92 97); - --dialog-button-bg-color:var(--csstools-light-dark-toggle--29, rgb(12 12 13 / 0.1)); - --csstools-light-dark-toggle--30:var(--csstools-color-scheme--light) rgb(115 115 115); - --dialog-button-hover-bg-color:var(--csstools-light-dark-toggle--30, rgb(12 12 13 / 0.3)); + --sidebar-narrow-bg-color: light-dark( + rgb(212 212 215 / 0.9), + rgb(42 42 46 / 0.9) + ); + --sidebar-toolbar-bg-color: light-dark(rgb(245 246 247), rgb(50 50 52)); + --toolbar-bg-color: light-dark(rgb(249 249 250), rgb(56 56 61)); + --toolbar-border-color: light-dark(rgb(184 184 184), rgb(12 12 13)); + --toggled-btn-color: light-dark(rgb(0 0 0), rgb(255 255 255)); + --dropdown-btn-bg-color: light-dark(rgb(215 215 219), rgb(74 74 79)); + --field-color: light-dark(rgb(6 6 6), rgb(250 250 250)); + --field-bg-color: light-dark(rgb(255 255 255), rgb(64 64 68)); + --field-border-color: light-dark(rgb(187 187 188), rgb(115 115 115)); + --treeitem-color: light-dark(rgb(0 0 0 / 0.8), rgb(255 255 255 / 0.8)); + --treeitem-bg-color: light-dark(rgb(0 0 0 / 0.15), rgb(255 255 255 / 0.15)); + --treeitem-hover-color: light-dark( + rgb(0 0 0 / 0.9), + rgb(255 255 255 / 0.9) + ); + --treeitem-selected-color: light-dark( + rgb(0 0 0 / 0.9), + rgb(255 255 255 / 0.9) + ); + --treeitem-selected-bg-color: light-dark( + rgb(0 0 0 / 0.25), + rgb(255 255 255 / 0.25) + ); + --thumbnail-hover-color: light-dark( + rgb(0 0 0 / 0.1), + rgb(255 255 255 / 0.1) + ); + --thumbnail-selected-color: light-dark( + rgb(0 0 0 / 0.2), + rgb(255 255 255 / 0.2) + ); + --doorhanger-bg-color: light-dark(rgb(255 255 255), #42414d); + --doorhanger-border-color: light-dark(rgb(12 12 13 / 0.2), rgb(39 39 43)); + --doorhanger-hover-color: light-dark(rgb(12 12 13), rgb(249 249 250)); + --doorhanger-separator-color: light-dark(rgb(222 222 222), rgb(92 92 97)); + --dialog-button-bg-color: light-dark(rgb(12 12 13 / 0.1), rgb(92 92 97)); + --dialog-button-hover-bg-color: light-dark( + rgb(12 12 13 / 0.3), + rgb(115 115 115) + ); } } -[dir="rtl"]:root{ - --dir-factor:-1; - --inline-start:right; - --inline-end:left; -} +@supports not (color: light-dark(tan, tan)) { + :root * { + --csstools-light-dark-toggle--0: var(--csstools-color-scheme--light) + rgb(249 249 250); -@media screen and (forced-colors: active){ - :root{ - --button-hover-color:Highlight; - --toolbar-icon-opacity:1; - --toolbar-icon-bg-color:ButtonText; - --toolbar-icon-hover-bg-color:ButtonFace; - --toggled-hover-active-btn-color:ButtonText; - --toggled-hover-btn-outline:2px solid ButtonBorder; - --toolbar-border-color:CanvasText; - --toolbar-border-bottom:1px solid var(--toolbar-border-color); - --toolbar-box-shadow:none; - --toggled-btn-color:HighlightText; - --toggled-btn-bg-color:LinkText; - --doorhanger-hover-color:ButtonFace; - --doorhanger-border-color-whcm:1px solid ButtonText; - --doorhanger-triangle-opacity-whcm:0; - --dialog-button-border:1px solid Highlight; - --dialog-button-hover-bg-color:Highlight; - --dialog-button-hover-color:ButtonFace; - --dropdown-btn-border:1px solid ButtonText; - --field-border-color:ButtonText; - --main-color:CanvasText; - --separator-color:GrayText; - --doorhanger-separator-color:GrayText; - --toolbarSidebar-box-shadow:none; - --toolbarSidebar-border-bottom:1px solid var(--toolbar-border-color); + --main-color: var(--csstools-light-dark-toggle--0, rgb(12 12 13)); + --csstools-light-dark-toggle--1: var(--csstools-color-scheme--light) + rgb(42 42 46); + --body-bg-color: var(--csstools-light-dark-toggle--1, rgb(212 212 215)); + --csstools-light-dark-toggle--2: var(--csstools-color-scheme--light) + rgb(0 96 223); + --progressBar-color: var(--csstools-light-dark-toggle--2, rgb(10 132 255)); + --csstools-light-dark-toggle--3: var(--csstools-color-scheme--light) + rgb(40 40 43); + --progressBar-bg-color: var( + --csstools-light-dark-toggle--3, + rgb(221 221 222) + ); + --csstools-light-dark-toggle--4: var(--csstools-color-scheme--light) + rgb(20 68 133); + --progressBar-blend-color: var( + --csstools-light-dark-toggle--4, + rgb(116 177 239) + ); + --csstools-light-dark-toggle--5: var(--csstools-color-scheme--light) + rgb(121 121 123); + --scrollbar-color: var(--csstools-light-dark-toggle--5, auto); + --csstools-light-dark-toggle--6: var(--csstools-color-scheme--light) + rgb(35 35 39); + --scrollbar-bg-color: var(--csstools-light-dark-toggle--6, auto); + --csstools-light-dark-toggle--7: var(--csstools-color-scheme--light) + rgb(255 255 255); + --toolbar-icon-bg-color: var(--csstools-light-dark-toggle--7, rgb(0 0 0)); + --csstools-light-dark-toggle--8: var(--csstools-color-scheme--light) + rgb(255 255 255); + --toolbar-icon-hover-bg-color: var( + --csstools-light-dark-toggle--8, + rgb(0 0 0) + ); + + --csstools-light-dark-toggle--9: var(--csstools-color-scheme--light) + rgb(42 42 46 / 0.9); + + --sidebar-narrow-bg-color: var( + --csstools-light-dark-toggle--9, + rgb(212 212 215 / 0.9) + ); + --csstools-light-dark-toggle--10: var(--csstools-color-scheme--light) + rgb(50 50 52); + --sidebar-toolbar-bg-color: var( + --csstools-light-dark-toggle--10, + rgb(245 246 247) + ); + --csstools-light-dark-toggle--11: var(--csstools-color-scheme--light) + rgb(56 56 61); + --toolbar-bg-color: var(--csstools-light-dark-toggle--11, rgb(249 249 250)); + --csstools-light-dark-toggle--12: var(--csstools-color-scheme--light) + rgb(12 12 13); + --toolbar-border-color: var( + --csstools-light-dark-toggle--12, + rgb(184 184 184) + ); + --csstools-light-dark-toggle--13: var(--csstools-color-scheme--light) + rgb(255 255 255); + --toggled-btn-color: var(--csstools-light-dark-toggle--13, rgb(0 0 0)); + --csstools-light-dark-toggle--14: var(--csstools-color-scheme--light) + rgb(74 74 79); + --dropdown-btn-bg-color: var( + --csstools-light-dark-toggle--14, + rgb(215 215 219) + ); + --csstools-light-dark-toggle--15: var(--csstools-color-scheme--light) + rgb(250 250 250); + --field-color: var(--csstools-light-dark-toggle--15, rgb(6 6 6)); + --csstools-light-dark-toggle--16: var(--csstools-color-scheme--light) + rgb(64 64 68); + --field-bg-color: var(--csstools-light-dark-toggle--16, rgb(255 255 255)); + --csstools-light-dark-toggle--17: var(--csstools-color-scheme--light) + rgb(115 115 115); + --field-border-color: var( + --csstools-light-dark-toggle--17, + rgb(187 187 188) + ); + --csstools-light-dark-toggle--18: var(--csstools-color-scheme--light) + rgb(255 255 255 / 0.8); + --treeitem-color: var(--csstools-light-dark-toggle--18, rgb(0 0 0 / 0.8)); + --csstools-light-dark-toggle--19: var(--csstools-color-scheme--light) + rgb(255 255 255 / 0.15); + --treeitem-bg-color: var( + --csstools-light-dark-toggle--19, + rgb(0 0 0 / 0.15) + ); + --csstools-light-dark-toggle--20: var(--csstools-color-scheme--light) + rgb(255 255 255 / 0.9); + --treeitem-hover-color: var( + --csstools-light-dark-toggle--20, + rgb(0 0 0 / 0.9) + ); + --csstools-light-dark-toggle--21: var(--csstools-color-scheme--light) + rgb(255 255 255 / 0.9); + --treeitem-selected-color: var( + --csstools-light-dark-toggle--21, + rgb(0 0 0 / 0.9) + ); + --csstools-light-dark-toggle--22: var(--csstools-color-scheme--light) + rgb(255 255 255 / 0.25); + --treeitem-selected-bg-color: var( + --csstools-light-dark-toggle--22, + rgb(0 0 0 / 0.25) + ); + --csstools-light-dark-toggle--23: var(--csstools-color-scheme--light) + rgb(255 255 255 / 0.1); + --thumbnail-hover-color: var( + --csstools-light-dark-toggle--23, + rgb(0 0 0 / 0.1) + ); + --csstools-light-dark-toggle--24: var(--csstools-color-scheme--light) + rgb(255 255 255 / 0.2); + --thumbnail-selected-color: var( + --csstools-light-dark-toggle--24, + rgb(0 0 0 / 0.2) + ); + --csstools-light-dark-toggle--25: var(--csstools-color-scheme--light) + #42414d; + --doorhanger-bg-color: var( + --csstools-light-dark-toggle--25, + rgb(255 255 255) + ); + --csstools-light-dark-toggle--26: var(--csstools-color-scheme--light) + rgb(39 39 43); + --doorhanger-border-color: var( + --csstools-light-dark-toggle--26, + rgb(12 12 13 / 0.2) + ); + --csstools-light-dark-toggle--27: var(--csstools-color-scheme--light) + rgb(249 249 250); + --doorhanger-hover-color: var( + --csstools-light-dark-toggle--27, + rgb(12 12 13) + ); + --csstools-light-dark-toggle--28: var(--csstools-color-scheme--light) + rgb(92 92 97); + --doorhanger-separator-color: var( + --csstools-light-dark-toggle--28, + rgb(222 222 222) + ); + --csstools-light-dark-toggle--29: var(--csstools-color-scheme--light) + rgb(92 92 97); + --dialog-button-bg-color: var( + --csstools-light-dark-toggle--29, + rgb(12 12 13 / 0.1) + ); + --csstools-light-dark-toggle--30: var(--csstools-color-scheme--light) + rgb(115 115 115); + --dialog-button-hover-bg-color: var( + --csstools-light-dark-toggle--30, + rgb(12 12 13 / 0.3) + ); } } -@media screen and (prefers-reduced-motion: reduce){ - :root{ - --sidebar-transition-duration:0; +[dir='rtl']:root { + --dir-factor: -1; + --inline-start: right; + --inline-end: left; +} + +@media screen and (forced-colors: active) { + :root { + --button-hover-color: Highlight; + --toolbar-icon-opacity: 1; + --toolbar-icon-bg-color: ButtonText; + --toolbar-icon-hover-bg-color: ButtonFace; + --toggled-hover-active-btn-color: ButtonText; + --toggled-hover-btn-outline: 2px solid ButtonBorder; + --toolbar-border-color: CanvasText; + --toolbar-border-bottom: 1px solid var(--toolbar-border-color); + --toolbar-box-shadow: none; + --toggled-btn-color: HighlightText; + --toggled-btn-bg-color: LinkText; + --doorhanger-hover-color: ButtonFace; + --doorhanger-border-color-whcm: 1px solid ButtonText; + --doorhanger-triangle-opacity-whcm: 0; + --dialog-button-border: 1px solid Highlight; + --dialog-button-hover-bg-color: Highlight; + --dialog-button-hover-color: ButtonFace; + --dropdown-btn-border: 1px solid ButtonText; + --field-border-color: ButtonText; + --main-color: CanvasText; + --separator-color: GrayText; + --doorhanger-separator-color: GrayText; + --toolbarSidebar-box-shadow: none; + --toolbarSidebar-border-bottom: 1px solid var(--toolbar-border-color); } } -@keyframes progressIndeterminate{ - 0%{ - transform:translateX(calc(-142px * var(--dir-factor))); - } - - 100%{ - transform:translateX(0); +@media screen and (prefers-reduced-motion: reduce) { + :root { + --sidebar-transition-duration: 0; } } -html[data-toolbar-density="compact"]{ - --toolbar-height:30px; +@keyframes progressIndeterminate { + 0% { + transform: translateX(calc(-142px * var(--dir-factor))); } -html[data-toolbar-density="touch"]{ - --toolbar-height:44px; + 100% { + transform: translateX(0); } +} + +html[data-toolbar-density='compact'] { + --toolbar-height: 30px; +} + +html[data-toolbar-density='touch'] { + --toolbar-height: 44px; +} html, -body{ - height:100%; - width:100%; +body { + height: 100%; + width: 100%; } -body{ - margin:0; - background-color:var(--body-bg-color); - scrollbar-color:var(--scrollbar-color) var(--scrollbar-bg-color); +body { + margin: 0; + background-color: var(--body-bg-color); + scrollbar-color: var(--scrollbar-color) var(--scrollbar-bg-color); } -body.wait::before{ - content:""; - position:fixed; - width:100%; - height:100%; - z-index:100000; - cursor:wait; - } -.visuallyHidden{ - position:absolute; - top:0; - left:0; - border:0; - margin:0; - padding:0; - width:0; - height:0; - overflow:hidden; - white-space:nowrap; - font-size:0; +body.wait::before { + content: ''; + position: fixed; + width: 100%; + height: 100%; + z-index: 100000; + cursor: wait; +} +.visuallyHidden { + position: absolute; + top: 0; + left: 0; + border: 0; + margin: 0; + padding: 0; + width: 0; + height: 0; + overflow: hidden; + white-space: nowrap; + font-size: 0; } .hidden, -[hidden]{ - display:none !important; +[hidden] { + display: none !important; } -#viewerContainer.pdfPresentationMode:fullscreen{ - top:0; - background-color:rgb(0 0 0); - width:100%; - height:100%; - overflow:hidden; - cursor:none; - -webkit-user-select:none; - -moz-user-select:none; - user-select:none; +#viewerContainer.pdfPresentationMode:fullscreen { + top: 0; + background-color: rgb(0 0 0); + width: 100%; + height: 100%; + overflow: hidden; + cursor: none; + -webkit-user-select: none; + -moz-user-select: none; + user-select: none; } -.pdfPresentationMode:fullscreen section:not([data-internal-link]){ - pointer-events:none; +.pdfPresentationMode:fullscreen section:not([data-internal-link]) { + pointer-events: none; } -.pdfPresentationMode:fullscreen .textLayer span{ - cursor:none; +.pdfPresentationMode:fullscreen .textLayer span { + cursor: none; } .pdfPresentationMode.pdfPresentationModeControls > *, -.pdfPresentationMode.pdfPresentationModeControls .textLayer span{ - cursor:default; +.pdfPresentationMode.pdfPresentationModeControls .textLayer span { + cursor: default; } -#outerContainer{ - width:100%; - height:100%; - position:relative; - margin:0; +#outerContainer { + width: 100%; + height: 100%; + position: relative; + margin: 0; } -#sidebarContainer{ - position:absolute; - inset-block:var(--toolbar-height) 0; - inset-inline-start:calc(-1 * var(--sidebar-width)); - width:var(--sidebar-width); - visibility:hidden; - z-index:1; - font:message-box; - border-top:1px solid transparent; - border-inline-end:var(--doorhanger-border-color-whcm); - transition-property:inset-inline-start; - transition-duration:var(--sidebar-transition-duration); - transition-timing-function:var(--sidebar-transition-timing-function); +#sidebarContainer { + position: absolute; + inset-block: var(--toolbar-height) 0; + inset-inline-start: calc(-1 * var(--sidebar-width)); + width: var(--sidebar-width); + visibility: hidden; + z-index: 1; + font: message-box; + border-top: 1px solid transparent; + border-inline-end: var(--doorhanger-border-color-whcm); + transition-property: inset-inline-start; + transition-duration: var(--sidebar-transition-duration); + transition-timing-function: var(--sidebar-transition-timing-function); } -#outerContainer:is(.sidebarMoving, .sidebarOpen) #sidebarContainer{ - visibility:visible; +#outerContainer:is(.sidebarMoving, .sidebarOpen) #sidebarContainer { + visibility: visible; } -#outerContainer.sidebarOpen #sidebarContainer{ - inset-inline-start:0; +#outerContainer.sidebarOpen #sidebarContainer { + inset-inline-start: 0; } -#mainContainer{ - position:absolute; - inset:0; - min-width:350px; - margin:0; - display:flex; - flex-direction:column; +#mainContainer { + position: absolute; + inset: 0; + min-width: 350px; + margin: 0; + display: flex; + flex-direction: column; } -#sidebarContent{ - inset-block:var(--toolbar-height) 0; - inset-inline-start:0; - overflow:auto; - position:absolute; - width:100%; - box-shadow:inset calc(-1px * var(--dir-factor)) 0 0 rgb(0 0 0 / 0.25); +#sidebarContent { + inset-block: var(--toolbar-height) 0; + inset-inline-start: 0; + overflow: auto; + position: absolute; + width: 100%; + box-shadow: inset calc(-1px * var(--dir-factor)) 0 0 rgb(0 0 0 / 0.25); } -#viewerContainer{ - overflow:auto; - position:absolute; - inset:var(--toolbar-height) 0 0; - outline:none; - z-index:0; +#viewerContainer { + overflow: auto; + position: absolute; + inset: var(--toolbar-height) 0 0; + outline: none; + z-index: 0; } -#viewerContainer:not(.pdfPresentationMode){ - transition-duration:var(--sidebar-transition-duration); - transition-timing-function:var(--sidebar-transition-timing-function); +#viewerContainer:not(.pdfPresentationMode) { + transition-duration: var(--sidebar-transition-duration); + transition-timing-function: var(--sidebar-transition-timing-function); } -#outerContainer.sidebarOpen #viewerContainer:not(.pdfPresentationMode){ - inset-inline-start:var(--sidebar-width); - transition-property:inset-inline-start; +#outerContainer.sidebarOpen #viewerContainer:not(.pdfPresentationMode) { + inset-inline-start: var(--sidebar-width); + transition-property: inset-inline-start; } -#sidebarContainer :is(input, button, select){ - font:message-box; +#sidebarContainer :is(input, button, select) { + font: message-box; } -.toolbar{ - z-index:2; +.toolbar { + z-index: 2; } -#toolbarSidebar{ - width:100%; - height:var(--toolbar-height); - background-color:var(--sidebar-toolbar-bg-color); - box-shadow:var(--toolbarSidebar-box-shadow); - border-bottom:var(--toolbarSidebar-border-bottom); - padding:var(--toolbar-vertical-padding) var(--toolbar-horizontal-padding); - justify-content:space-between; +#toolbarSidebar { + width: 100%; + height: var(--toolbar-height); + background-color: var(--sidebar-toolbar-bg-color); + box-shadow: var(--toolbarSidebar-box-shadow); + border-bottom: var(--toolbarSidebar-border-bottom); + padding: var(--toolbar-vertical-padding) var(--toolbar-horizontal-padding); + justify-content: space-between; } -#toolbarSidebar #toolbarSidebarLeft{ - width:auto; - height:100%; - } - -:is(#toolbarSidebar #toolbarSidebarLeft) #viewThumbnail::before{ - -webkit-mask-image:var(--toolbarButton-viewThumbnail-icon); - mask-image:var(--toolbarButton-viewThumbnail-icon); - } - -:is(#toolbarSidebar #toolbarSidebarLeft) #viewOutline::before{ - -webkit-mask-image:var(--toolbarButton-viewOutline-icon); - mask-image:var(--toolbarButton-viewOutline-icon); - transform:scaleX(var(--dir-factor)); - } - -:is(#toolbarSidebar #toolbarSidebarLeft) #viewAttachments::before{ - -webkit-mask-image:var(--toolbarButton-viewAttachments-icon); - mask-image:var(--toolbarButton-viewAttachments-icon); - } - -:is(#toolbarSidebar #toolbarSidebarLeft) #viewLayers::before{ - -webkit-mask-image:var(--toolbarButton-viewLayers-icon); - mask-image:var(--toolbarButton-viewLayers-icon); - } - -#toolbarSidebar #toolbarSidebarRight{ - width:auto; - height:100%; - padding-inline-end:2px; - } - -#sidebarResizer{ - position:absolute; - inset-block:0; - inset-inline-end:-6px; - width:6px; - z-index:200; - cursor:ew-resize; +#toolbarSidebar #toolbarSidebarLeft { + width: auto; + height: 100%; } -#outerContainer.sidebarOpen #loadingBar{ - inset-inline-start:var(--sidebar-width); +:is(#toolbarSidebar #toolbarSidebarLeft) #viewThumbnail::before { + -webkit-mask-image: var(--toolbarButton-viewThumbnail-icon); + mask-image: var(--toolbarButton-viewThumbnail-icon); +} + +:is(#toolbarSidebar #toolbarSidebarLeft) #viewOutline::before { + -webkit-mask-image: var(--toolbarButton-viewOutline-icon); + mask-image: var(--toolbarButton-viewOutline-icon); + transform: scaleX(var(--dir-factor)); +} + +:is(#toolbarSidebar #toolbarSidebarLeft) #viewAttachments::before { + -webkit-mask-image: var(--toolbarButton-viewAttachments-icon); + mask-image: var(--toolbarButton-viewAttachments-icon); +} + +:is(#toolbarSidebar #toolbarSidebarLeft) #viewLayers::before { + -webkit-mask-image: var(--toolbarButton-viewLayers-icon); + mask-image: var(--toolbarButton-viewLayers-icon); +} + +#toolbarSidebar #toolbarSidebarRight { + width: auto; + height: 100%; + padding-inline-end: 2px; +} + +#sidebarResizer { + position: absolute; + inset-block: 0; + inset-inline-end: -6px; + width: 6px; + z-index: 200; + cursor: ew-resize; +} + +#outerContainer.sidebarOpen #loadingBar { + inset-inline-start: var(--sidebar-width); } #outerContainer.sidebarResizing - :is(#sidebarContainer, #viewerContainer, #loadingBar){ - transition-duration:0s; + :is(#sidebarContainer, #viewerContainer, #loadingBar) { + transition-duration: 0s; } .doorHanger, -.doorHangerRight{ - border-radius:2px; - box-shadow:0 1px 5px var(--doorhanger-border-color), 0 0 0 1px var(--doorhanger-border-color); - border:var(--doorhanger-border-color-whcm); - background-color:var(--doorhanger-bg-color); - inset-block-start:calc(100% + var(--doorhanger-height) - 2px); +.doorHangerRight { + border-radius: 2px; + box-shadow: + 0 1px 5px var(--doorhanger-border-color), + 0 0 0 1px var(--doorhanger-border-color); + border: var(--doorhanger-border-color-whcm); + background-color: var(--doorhanger-bg-color); + inset-block-start: calc(100% + var(--doorhanger-height) - 2px); } -:is(.doorHanger,.doorHangerRight)::after,:is(.doorHanger,.doorHangerRight)::before{ - bottom:100%; - border-style:solid; - border-color:transparent; - content:""; - height:0; - width:0; - position:absolute; - pointer-events:none; - opacity:var(--doorhanger-triangle-opacity-whcm); - } - -:is(.doorHanger,.doorHangerRight)::before{ - border-width:calc(var(--doorhanger-height) + 2px); - border-bottom-color:var(--doorhanger-border-color); - } - -:is(.doorHanger,.doorHangerRight)::after{ - border-width:var(--doorhanger-height); - } - -.doorHangerRight{ - inset-inline-end:calc(50% - var(--doorhanger-height) - 1px); +:is(.doorHanger, .doorHangerRight)::after, +:is(.doorHanger, .doorHangerRight)::before { + bottom: 100%; + border-style: solid; + border-color: transparent; + content: ''; + height: 0; + width: 0; + position: absolute; + pointer-events: none; + opacity: var(--doorhanger-triangle-opacity-whcm); } -.doorHangerRight::before{ - inset-inline-end:-1px; - } - -.doorHangerRight::after{ - border-bottom-color:var(--doorhanger-bg-color); - inset-inline-end:1px; - } - -.doorHanger{ - inset-inline-start:calc(50% - var(--doorhanger-height) - 1px); +:is(.doorHanger, .doorHangerRight)::before { + border-width: calc(var(--doorhanger-height) + 2px); + border-bottom-color: var(--doorhanger-border-color); } -.doorHanger::before{ - inset-inline-start:-1px; - } - -.doorHanger::after{ - border-bottom-color:var(--toolbar-bg-color); - inset-inline-start:1px; - } - -.dialogButton{ - border:none; - background:none; - width:28px; - height:28px; - outline:none; +:is(.doorHanger, .doorHangerRight)::after { + border-width: var(--doorhanger-height); } -.dialogButton:is(:hover, :focus-visible){ - background-color:var(--dialog-button-hover-bg-color); +.doorHangerRight { + inset-inline-end: calc(50% - var(--doorhanger-height) - 1px); } -.dialogButton:is(:hover, :focus-visible) > span{ - color:var(--dialog-button-hover-color); +.doorHangerRight::before { + inset-inline-end: -1px; } -.splitToolbarButtonSeparator{ - float:var(--inline-start); - width:0; - height:62%; - border-left:1px solid var(--separator-color); - border-right:none; +.doorHangerRight::after { + border-bottom-color: var(--doorhanger-bg-color); + inset-inline-end: 1px; } -.dialogButton{ - min-width:16px; - margin:2px 1px; - padding:2px 6px 0; - border:none; - border-radius:2px; - color:var(--main-color); - font-size:12px; - line-height:14px; - -webkit-user-select:none; - -moz-user-select:none; - user-select:none; - cursor:default; - box-sizing:border-box; +.doorHanger { + inset-inline-start: calc(50% - var(--doorhanger-height) - 1px); } -.treeItemToggler::before{ - position:absolute; - display:inline-block; - width:16px; - height:16px; - - content:""; - background-color:var(--toolbar-icon-bg-color); - -webkit-mask-size:cover; - mask-size:cover; +.doorHanger::before { + inset-inline-start: -1px; } -#sidebarToggleButton::before{ - -webkit-mask-image:var(--toolbarButton-sidebarToggle-icon); - mask-image:var(--toolbarButton-sidebarToggle-icon); - transform:scaleX(var(--dir-factor)); +.doorHanger::after { + border-bottom-color: var(--toolbar-bg-color); + inset-inline-start: 1px; } -#secondaryToolbarToggleButton::before{ - -webkit-mask-image:var(--toolbarButton-secondaryToolbarToggle-icon); - mask-image:var(--toolbarButton-secondaryToolbarToggle-icon); - transform:scaleX(var(--dir-factor)); +.dialogButton { + border: none; + background: none; + width: 28px; + height: 28px; + outline: none; } -#previous::before{ - -webkit-mask-image:var(--toolbarButton-pageUp-icon); - mask-image:var(--toolbarButton-pageUp-icon); +.dialogButton:is(:hover, :focus-visible) { + background-color: var(--dialog-button-hover-bg-color); } -#next::before{ - -webkit-mask-image:var(--toolbarButton-pageDown-icon); - mask-image:var(--toolbarButton-pageDown-icon); +.dialogButton:is(:hover, :focus-visible) > span { + color: var(--dialog-button-hover-color); } -#zoomOutButton::before{ - -webkit-mask-image:var(--toolbarButton-zoomOut-icon); - mask-image:var(--toolbarButton-zoomOut-icon); +.splitToolbarButtonSeparator { + float: var(--inline-start); + width: 0; + height: 62%; + border-left: 1px solid var(--separator-color); + border-right: none; } -#zoomInButton::before{ - -webkit-mask-image:var(--toolbarButton-zoomIn-icon); - mask-image:var(--toolbarButton-zoomIn-icon); +.dialogButton { + min-width: 16px; + margin: 2px 1px; + padding: 2px 6px 0; + border: none; + border-radius: 2px; + color: var(--main-color); + font-size: 12px; + line-height: 14px; + -webkit-user-select: none; + -moz-user-select: none; + user-select: none; + cursor: default; + box-sizing: border-box; } -#editorCommentButton::before{ - -webkit-mask-image:var(--toolbarButton-editorComment-icon); - mask-image:var(--toolbarButton-editorComment-icon); - transform:scaleX(var(--dir-factor)); +.treeItemToggler::before { + position: absolute; + display: inline-block; + width: 16px; + height: 16px; + + content: ''; + background-color: var(--toolbar-icon-bg-color); + -webkit-mask-size: cover; + mask-size: cover; } -#editorFreeTextButton::before{ - -webkit-mask-image:var(--toolbarButton-editorFreeText-icon); - mask-image:var(--toolbarButton-editorFreeText-icon); +#sidebarToggleButton::before { + -webkit-mask-image: var(--toolbarButton-sidebarToggle-icon); + mask-image: var(--toolbarButton-sidebarToggle-icon); + transform: scaleX(var(--dir-factor)); } -#editorHighlightButton::before{ - -webkit-mask-image:var(--toolbarButton-editorHighlight-icon); - mask-image:var(--toolbarButton-editorHighlight-icon); +#secondaryToolbarToggleButton::before { + -webkit-mask-image: var(--toolbarButton-secondaryToolbarToggle-icon); + mask-image: var(--toolbarButton-secondaryToolbarToggle-icon); + transform: scaleX(var(--dir-factor)); } -#editorInkButton::before{ - -webkit-mask-image:var(--toolbarButton-editorInk-icon); - mask-image:var(--toolbarButton-editorInk-icon); +#previous::before { + -webkit-mask-image: var(--toolbarButton-pageUp-icon); + mask-image: var(--toolbarButton-pageUp-icon); } -#editorStampButton::before{ - -webkit-mask-image:var(--toolbarButton-editorStamp-icon); - mask-image:var(--toolbarButton-editorStamp-icon); +#next::before { + -webkit-mask-image: var(--toolbarButton-pageDown-icon); + mask-image: var(--toolbarButton-pageDown-icon); } -#editorSignatureButton::before{ - -webkit-mask-image:var(--toolbarButton-editorSignature-icon); - mask-image:var(--toolbarButton-editorSignature-icon); +#zoomOutButton::before { + -webkit-mask-image: var(--toolbarButton-zoomOut-icon); + mask-image: var(--toolbarButton-zoomOut-icon); } -#printButton::before{ - -webkit-mask-image:var(--toolbarButton-print-icon); - mask-image:var(--toolbarButton-print-icon); +#zoomInButton::before { + -webkit-mask-image: var(--toolbarButton-zoomIn-icon); + mask-image: var(--toolbarButton-zoomIn-icon); } -#downloadButton::before{ - -webkit-mask-image:var(--toolbarButton-download-icon); - mask-image:var(--toolbarButton-download-icon); +#editorCommentButton::before { + -webkit-mask-image: var(--toolbarButton-editorComment-icon); + mask-image: var(--toolbarButton-editorComment-icon); + transform: scaleX(var(--dir-factor)); } -#currentOutlineItem::before{ - -webkit-mask-image:var(--toolbarButton-currentOutlineItem-icon); - mask-image:var(--toolbarButton-currentOutlineItem-icon); - transform:scaleX(var(--dir-factor)); +#editorFreeTextButton::before { + -webkit-mask-image: var(--toolbarButton-editorFreeText-icon); + mask-image: var(--toolbarButton-editorFreeText-icon); } -#viewFindButton::before{ - -webkit-mask-image:var(--toolbarButton-search-icon); - mask-image:var(--toolbarButton-search-icon); +#editorHighlightButton::before { + -webkit-mask-image: var(--toolbarButton-editorHighlight-icon); + mask-image: var(--toolbarButton-editorHighlight-icon); } -.pdfSidebarNotification::after{ - position:absolute; - display:inline-block; - top:2px; - inset-inline-end:2px; - content:""; - background-color:rgb(112 219 85); - height:9px; - width:9px; - border-radius:50%; +#editorInkButton::before { + -webkit-mask-image: var(--toolbarButton-editorInk-icon); + mask-image: var(--toolbarButton-editorInk-icon); } -.verticalToolbarSeparator{ - display:block; - margin-inline:2px; - width:0; - height:80%; - border-left:1px solid var(--separator-color); - border-right:none; - box-sizing:border-box; +#editorStampButton::before { + -webkit-mask-image: var(--toolbarButton-editorStamp-icon); + mask-image: var(--toolbarButton-editorStamp-icon); } -.horizontalToolbarSeparator{ - display:block; - margin:6px 0; - border-top:1px solid var(--doorhanger-separator-color); - border-bottom:none; - height:0; - width:100%; +#editorSignatureButton::before { + -webkit-mask-image: var(--toolbarButton-editorSignature-icon); + mask-image: var(--toolbarButton-editorSignature-icon); } -.toggleButton{ - display:inline; +#printButton::before { + -webkit-mask-image: var(--toolbarButton-print-icon); + mask-image: var(--toolbarButton-print-icon); } -.toggleButton:has( > input:checked){ - color:var(--toggled-btn-color); - background-color:var(--toggled-btn-bg-color); - } - -.toggleButton:is(:hover,:has( > input:focus-visible)){ - color:var(--toggled-btn-color); - background-color:var(--button-hover-color); - } - -.toggleButton > input{ - position:absolute; - top:50%; - left:50%; - opacity:0; - width:0; - height:0; - } - -.toolbarField{ - padding:4px 7px; - margin:3px 0; - border-radius:2px; - background-color:var(--field-bg-color); - background-clip:padding-box; - border:1px solid var(--field-border-color); - box-shadow:none; - color:var(--field-color); - font-size:12px; - line-height:16px; - outline:none; +#downloadButton::before { + -webkit-mask-image: var(--toolbarButton-download-icon); + mask-image: var(--toolbarButton-download-icon); } -.toolbarField:focus{ - border-color:#0a84ff; - } - -#pageNumber{ - -moz-appearance:textfield; - text-align:end; - width:40px; - background-size:0 0; - transition-property:none; +#currentOutlineItem::before { + -webkit-mask-image: var(--toolbarButton-currentOutlineItem-icon); + mask-image: var(--toolbarButton-currentOutlineItem-icon); + transform: scaleX(var(--dir-factor)); } -#pageNumber::-webkit-inner-spin-button{ - -webkit-appearance:none; - } - -.loadingInput:has( > .loading:is(#pageNumber))::after{ - display:inline; - visibility:visible; - - transition-property:visibility; - transition-delay:var(--loading-icon-delay); - } - -.loadingInput{ - position:relative; +#viewFindButton::before { + -webkit-mask-image: var(--toolbarButton-search-icon); + mask-image: var(--toolbarButton-search-icon); } -.loadingInput::after{ - position:absolute; - visibility:hidden; - display:none; - width:var(--icon-size); - height:var(--icon-size); +.pdfSidebarNotification::after { + position: absolute; + display: inline-block; + top: 2px; + inset-inline-end: 2px; + content: ''; + background-color: rgb(112 219 85); + height: 9px; + width: 9px; + border-radius: 50%; +} - content:""; - background-color:var(--toolbar-icon-bg-color); - -webkit-mask-size:cover; - mask-size:cover; - -webkit-mask-image:var(--loading-icon); - mask-image:var(--loading-icon); - } +.verticalToolbarSeparator { + display: block; + margin-inline: 2px; + width: 0; + height: 80%; + border-left: 1px solid var(--separator-color); + border-right: none; + box-sizing: border-box; +} -.loadingInput.start::after{ - inset-inline-start:4px; - } +.horizontalToolbarSeparator { + display: block; + margin: 6px 0; + border-top: 1px solid var(--doorhanger-separator-color); + border-bottom: none; + height: 0; + width: 100%; +} -.loadingInput.end::after{ - inset-inline-end:4px; - } +.toggleButton { + display: inline; +} + +.toggleButton:has(> input:checked) { + color: var(--toggled-btn-color); + background-color: var(--toggled-btn-bg-color); +} + +.toggleButton:is(:hover, :has(> input:focus-visible)) { + color: var(--toggled-btn-color); + background-color: var(--button-hover-color); +} + +.toggleButton > input { + position: absolute; + top: 50%; + left: 50%; + opacity: 0; + width: 0; + height: 0; +} + +.toolbarField { + padding: 4px 7px; + margin: 3px 0; + border-radius: 2px; + background-color: var(--field-bg-color); + background-clip: padding-box; + border: 1px solid var(--field-border-color); + box-shadow: none; + color: var(--field-color); + font-size: 12px; + line-height: 16px; + outline: none; +} + +.toolbarField:focus { + border-color: #0a84ff; +} + +#pageNumber { + -moz-appearance: textfield; + text-align: end; + width: 40px; + background-size: 0 0; + transition-property: none; +} + +#pageNumber::-webkit-inner-spin-button { + -webkit-appearance: none; +} + +.loadingInput:has(> .loading:is(#pageNumber))::after { + display: inline; + visibility: visible; + + transition-property: visibility; + transition-delay: var(--loading-icon-delay); +} + +.loadingInput { + position: relative; +} + +.loadingInput::after { + position: absolute; + visibility: hidden; + display: none; + width: var(--icon-size); + height: var(--icon-size); + + content: ''; + background-color: var(--toolbar-icon-bg-color); + -webkit-mask-size: cover; + mask-size: cover; + -webkit-mask-image: var(--loading-icon); + mask-image: var(--loading-icon); +} + +.loadingInput.start::after { + inset-inline-start: 4px; +} + +.loadingInput.end::after { + inset-inline-end: 4px; +} #thumbnailView, #outlineView, #attachmentsView, -#layersView{ - position:absolute; - width:calc(100% - 8px); - inset-block:0; - padding:4px 4px 0; - overflow:auto; - -webkit-user-select:none; - -moz-user-select:none; - user-select:none; +#layersView { + position: absolute; + width: calc(100% - 8px); + inset-block: 0; + padding: 4px 4px 0; + overflow: auto; + -webkit-user-select: none; + -moz-user-select: none; + user-select: none; } -#thumbnailView{ - width:calc(100% - 60px); - padding:10px 30px 0; +#thumbnailView { + width: calc(100% - 60px); + padding: 10px 30px 0; } -#thumbnailView > a:is(:active, :focus){ - outline:0; +#thumbnailView > a:is(:active, :focus) { + outline: 0; } -.thumbnail{ - --thumbnail-width:0; - --thumbnail-height:0; +.thumbnail { + --thumbnail-width: 0; + --thumbnail-height: 0; - float:var(--inline-start); - width:var(--thumbnail-width); - height:var(--thumbnail-height); - margin:0 10px 5px; - padding:1px; - border:7px solid transparent; - border-radius:2px; + float: var(--inline-start); + width: var(--thumbnail-width); + height: var(--thumbnail-height); + margin: 0 10px 5px; + padding: 1px; + border: 7px solid transparent; + border-radius: 2px; } -#thumbnailView > a:last-of-type > .thumbnail{ - margin-bottom:10px; +#thumbnailView > a:last-of-type > .thumbnail { + margin-bottom: 10px; } a:focus > .thumbnail, -.thumbnail:hover{ - border-color:var(--thumbnail-hover-color); +.thumbnail:hover { + border-color: var(--thumbnail-hover-color); } -.thumbnail.selected{ - border-color:var(--thumbnail-selected-color) !important; +.thumbnail.selected { + border-color: var(--thumbnail-selected-color) !important; } -.thumbnailImage{ - width:var(--thumbnail-width); - height:var(--thumbnail-height); - opacity:0.9; +.thumbnailImage { + width: var(--thumbnail-width); + height: var(--thumbnail-height); + opacity: 0.9; } a:focus > .thumbnail > .thumbnailImage, -.thumbnail:hover > .thumbnailImage{ - opacity:0.95; +.thumbnail:hover > .thumbnailImage { + opacity: 0.95; } -.thumbnail.selected > .thumbnailImage{ - opacity:1 !important; +.thumbnail.selected > .thumbnailImage { + opacity: 1 !important; } -.thumbnail:not([data-loaded]) > .thumbnailImage{ - width:calc(var(--thumbnail-width) - 2px); - height:calc(var(--thumbnail-height) - 2px); - border:1px dashed rgb(132 132 132); +.thumbnail:not([data-loaded]) > .thumbnailImage { + width: calc(var(--thumbnail-width) - 2px); + height: calc(var(--thumbnail-height) - 2px); + border: 1px dashed rgb(132 132 132); } .treeWithDeepNesting > .treeItem, -.treeItem > .treeItems{ - margin-inline-start:20px; +.treeItem > .treeItems { + margin-inline-start: 20px; } -.treeItem > a{ - text-decoration:none; - display:inline-block; - min-width:calc(100% - 4px); - height:auto; - margin-bottom:1px; - padding:2px 0 5px; - padding-inline-start:4px; - border-radius:2px; - color:var(--treeitem-color); - font-size:13px; - line-height:15px; - -webkit-user-select:none; - -moz-user-select:none; - user-select:none; - white-space:normal; - cursor:pointer; +.treeItem > a { + text-decoration: none; + display: inline-block; + min-width: calc(100% - 4px); + height: auto; + margin-bottom: 1px; + padding: 2px 0 5px; + padding-inline-start: 4px; + border-radius: 2px; + color: var(--treeitem-color); + font-size: 13px; + line-height: 15px; + -webkit-user-select: none; + -moz-user-select: none; + user-select: none; + white-space: normal; + cursor: pointer; } -#layersView .treeItem > a *{ - cursor:pointer; +#layersView .treeItem > a * { + cursor: pointer; } -#layersView .treeItem > a > label{ - padding-inline-start:4px; +#layersView .treeItem > a > label { + padding-inline-start: 4px; } -#layersView .treeItem > a > label > input{ - float:var(--inline-start); - margin-top:1px; +#layersView .treeItem > a > label > input { + float: var(--inline-start); + margin-top: 1px; } -.treeItemToggler{ - position:relative; - float:var(--inline-start); - height:0; - width:0; - color:rgb(255 255 255 / 0.5); +.treeItemToggler { + position: relative; + float: var(--inline-start); + height: 0; + width: 0; + color: rgb(255 255 255 / 0.5); } -.treeItemToggler::before{ - inset-inline-end:4px; - -webkit-mask-image:var(--treeitem-expanded-icon); - mask-image:var(--treeitem-expanded-icon); +.treeItemToggler::before { + inset-inline-end: 4px; + -webkit-mask-image: var(--treeitem-expanded-icon); + mask-image: var(--treeitem-expanded-icon); } -.treeItemToggler.treeItemsHidden::before{ - -webkit-mask-image:var(--treeitem-collapsed-icon); - mask-image:var(--treeitem-collapsed-icon); - transform:scaleX(var(--dir-factor)); +.treeItemToggler.treeItemsHidden::before { + -webkit-mask-image: var(--treeitem-collapsed-icon); + mask-image: var(--treeitem-collapsed-icon); + transform: scaleX(var(--dir-factor)); } -.treeItemToggler.treeItemsHidden ~ .treeItems{ - display:none; +.treeItemToggler.treeItemsHidden ~ .treeItems { + display: none; } -.treeItem.selected > a{ - background-color:var(--treeitem-selected-bg-color); - color:var(--treeitem-selected-color); +.treeItem.selected > a { + background-color: var(--treeitem-selected-bg-color); + color: var(--treeitem-selected-color); } .treeItemToggler:hover, .treeItemToggler:hover + a, .treeItemToggler:hover ~ .treeItems, -.treeItem > a:hover{ - background-color:var(--treeitem-bg-color); - background-clip:padding-box; - border-radius:2px; - color:var(--treeitem-hover-color); +.treeItem > a:hover { + background-color: var(--treeitem-bg-color); + background-clip: padding-box; + border-radius: 2px; + color: var(--treeitem-hover-color); } -#outlineOptionsContainer{ - display:none; +#outlineOptionsContainer { + display: none; } -#sidebarContainer:has(#outlineView:not(.hidden)) #outlineOptionsContainer{ - display:inline flex; - } - -.dialogButton{ - width:auto; - margin:3px 4px 2px !important; - padding:2px 11px; - color:var(--main-color); - background-color:var(--dialog-button-bg-color); - border:var(--dialog-button-border) !important; +#sidebarContainer:has(#outlineView:not(.hidden)) #outlineOptionsContainer { + display: inline flex; } -dialog{ - margin:auto; - padding:15px; - border-spacing:4px; - color:var(--main-color); - font:message-box; - font-size:12px; - line-height:14px; - background-color:var(--doorhanger-bg-color); - border:1px solid rgb(0 0 0 / 0.5); - border-radius:4px; - box-shadow:0 1px 4px rgb(0 0 0 / 0.3); +.dialogButton { + width: auto; + margin: 3px 4px 2px !important; + padding: 2px 11px; + color: var(--main-color); + background-color: var(--dialog-button-bg-color); + border: var(--dialog-button-border) !important; } -dialog::backdrop{ - background-color:rgb(0 0 0 / 0.2); +dialog { + margin: auto; + padding: 15px; + border-spacing: 4px; + color: var(--main-color); + font: message-box; + font-size: 12px; + line-height: 14px; + background-color: var(--doorhanger-bg-color); + border: 1px solid rgb(0 0 0 / 0.5); + border-radius: 4px; + box-shadow: 0 1px 4px rgb(0 0 0 / 0.3); } -dialog > .row{ - display:table-row; +dialog::backdrop { + background-color: rgb(0 0 0 / 0.2); } -dialog > .row > *{ - display:table-cell; +dialog > .row { + display: table-row; } -dialog .toolbarField{ - margin:5px 0; +dialog > .row > * { + display: table-cell; } -dialog .separator{ - display:block; - margin:4px 0; - height:0; - width:100%; - border-top:1px solid var(--separator-color); - border-bottom:none; +dialog .toolbarField { + margin: 5px 0; } -dialog .buttonRow{ - text-align:center; - vertical-align:middle; +dialog .separator { + display: block; + margin: 4px 0; + height: 0; + width: 100%; + border-top: 1px solid var(--separator-color); + border-bottom: none; } -dialog :link{ - color:rgb(255 255 255); +dialog .buttonRow { + text-align: center; + vertical-align: middle; } -#passwordDialog{ - text-align:center; +dialog :link { + color: rgb(255 255 255); } -#passwordDialog .toolbarField{ - width:200px; +#passwordDialog { + text-align: center; } -#documentPropertiesDialog{ - text-align:left; +#passwordDialog .toolbarField { + width: 200px; } -#documentPropertiesDialog .row > *{ - min-width:100px; - text-align:start; +#documentPropertiesDialog { + text-align: left; } -#documentPropertiesDialog .row > span{ - width:125px; - word-wrap:break-word; +#documentPropertiesDialog .row > * { + min-width: 100px; + text-align: start; } -#documentPropertiesDialog .row > p{ - max-width:225px; - word-wrap:break-word; +#documentPropertiesDialog .row > span { + width: 125px; + word-wrap: break-word; } -#documentPropertiesDialog .buttonRow{ - margin-top:10px; +#documentPropertiesDialog .row > p { + max-width: 225px; + word-wrap: break-word; } -.grab-to-pan-grab{ - cursor:grab !important; +#documentPropertiesDialog .buttonRow { + margin-top: 10px; +} + +.grab-to-pan-grab { + cursor: grab !important; } .grab-to-pan-grab - *:not(input):not(textarea):not(button):not(select):not(:link){ - cursor:inherit !important; + *:not(input):not(textarea):not(button):not(select):not(:link) { + cursor: inherit !important; } .grab-to-pan-grab:active, -.grab-to-pan-grabbing{ - cursor:grabbing !important; +.grab-to-pan-grabbing { + cursor: grabbing !important; } -.grab-to-pan-grabbing{ - position:fixed; - background:rgb(0 0 0 / 0); - display:block; - inset:0; - overflow:hidden; - z-index:50000; +.grab-to-pan-grabbing { + position: fixed; + background: rgb(0 0 0 / 0); + display: block; + inset: 0; + overflow: hidden; + z-index: 50000; } -.toolbarButton{ - height:100%; - aspect-ratio:1; - display:flex; - align-items:center; - justify-content:center; - background:none; - border:none; - color:var(--main-color); - outline:none; - border-radius:2px; - box-sizing:border-box; - font:message-box; - flex:none; - position:relative; - padding:0; +.toolbarButton { + height: 100%; + aspect-ratio: 1; + display: flex; + align-items: center; + justify-content: center; + background: none; + border: none; + color: var(--main-color); + outline: none; + border-radius: 2px; + box-sizing: border-box; + font: message-box; + flex: none; + position: relative; + padding: 0; } -.toolbarButton > span{ - display:inline-block; - width:0; - height:0; - overflow:hidden; - } - -.toolbarButton::before{ - opacity:var(--toolbar-icon-opacity); - display:inline-block; - width:var(--icon-size); - height:var(--icon-size); - content:""; - background-color:var(--toolbar-icon-bg-color); - -webkit-mask-size:cover; - mask-size:cover; - -webkit-mask-position:center; - mask-position:center; - } - -.toolbarButton.toggled{ - background-color:var(--toggled-btn-bg-color); - color:var(--toggled-btn-color); - } - -.toolbarButton.toggled::before{ - background-color:var(--toggled-btn-color); - } - -.toolbarButton.toggled:hover{ - outline:var(--toggled-hover-btn-outline) !important; - } - -.toolbarButton.toggled:hover:active{ - background-color:var(--toggled-hover-active-btn-color); - } - -.toolbarButton:is(:hover,:focus-visible){ - background-color:var(--button-hover-color); - } - -.toolbarButton:is(:hover,:focus-visible)::before{ - background-color:var(--toolbar-icon-hover-bg-color); - } - -.toolbarButton:is([disabled="disabled"],[disabled]){ - opacity:0.5; - pointer-events:none; - } - -.toolbarButton.labeled{ - width:100%; - min-height:var(--menuitem-height); - justify-content:flex-start; - gap:8px; - padding-inline-start:12px; - aspect-ratio:unset; - text-align:start; - white-space:normal; - cursor:default; - } - -.toolbarButton.labeled:is(a){ - text-decoration:none; - } - -.toolbarButton.labeled[href="#"]:is(a){ - opacity:0.5; - pointer-events:none; - } - -.toolbarButton.labeled::before{ - opacity:var(--doorhanger-icon-opacity); - } - -.toolbarButton.labeled:is(:hover,:focus-visible){ - color:var(--doorhanger-hover-color); - } - -.toolbarButton.labeled > span{ - display:inline-block; - width:-moz-max-content; - width:max-content; - height:auto; - } - -.toolbarButtonWithContainer{ - height:100%; - aspect-ratio:1; - display:inline-block; - position:relative; - flex:none; +.toolbarButton > span { + display: inline-block; + width: 0; + height: 0; + overflow: hidden; } -.toolbarButtonWithContainer > .toolbarButton{ - width:100%; - height:100%; - } - -.toolbarButtonWithContainer .menu{ - padding-block:5px; - } - -.toolbarButtonWithContainer .menuContainer{ - height:auto; - max-height:calc( - var(--viewer-container-height) - var(--toolbar-height) - - var(--doorhanger-height) - ); - display:flex; - flex-direction:column; - box-sizing:border-box; - overflow-y:auto; - } - -.toolbarButtonWithContainer .editorParamsToolbar{ - --editor-toolbar-min-width:220px; - - height:auto; - min-width:var(--editor-toolbar-min-width); - width:-moz-max-content; - width:max-content; - position:absolute; - z-index:30000; - cursor:default; - } - -:is(.toolbarButtonWithContainer .editorParamsToolbar) :is(#editorStampAddImage,#editorSignatureAddSignature)::before{ - -webkit-mask-image:var(--editorParams-stampAddImage-icon); - mask-image:var(--editorParams-stampAddImage-icon); - } - -:is(.toolbarButtonWithContainer .editorParamsToolbar) .editorParamsLabel{ - flex:none; - font:menu; - font-size:13px; - font-style:normal; - font-weight:400; - line-height:150%; - width:-moz-fit-content; - width:fit-content; - inset-inline-start:0; - color:var(--main-color); - } - -:is(.toolbarButtonWithContainer .editorParamsToolbar) button:is(:hover,:focus-visible) .editorParamsLabel{ - color:var(--doorhanger-hover-color); - } - -:is(.toolbarButtonWithContainer .editorParamsToolbar) .editorParamsToolbarContainer{ - width:100%; - height:auto; - display:flex; - flex-direction:column; - box-sizing:border-box; - padding-inline:10px; - padding-block:10px; - } - -:is(:is(.toolbarButtonWithContainer .editorParamsToolbar) .editorParamsToolbarContainer) > .editorParamsSetter{ - min-height:26px; - display:flex; - align-items:center; - justify-content:space-between; - } - -:is(:is(.toolbarButtonWithContainer .editorParamsToolbar) .editorParamsToolbarContainer) .editorParamsColor{ - width:32px; - height:32px; - flex:none; - padding:0; - } - -:is(:is(.toolbarButtonWithContainer .editorParamsToolbar) .editorParamsToolbarContainer) .editorParamsSlider{ - background-color:transparent; - width:90px; - flex:0 1 0; - font:message-box; - } - -:is(:is(:is(.toolbarButtonWithContainer .editorParamsToolbar) .editorParamsToolbarContainer) .editorParamsSlider)::-moz-range-progress{ - background-color:black; - } - -:is(:is(:is(.toolbarButtonWithContainer .editorParamsToolbar) .editorParamsToolbarContainer) .editorParamsSlider)::-webkit-slider-runnable-track,:is(:is(:is(.toolbarButtonWithContainer .editorParamsToolbar) .editorParamsToolbarContainer) .editorParamsSlider)::-moz-range-track{ - background-color:black; - } - -:is(:is(:is(.toolbarButtonWithContainer .editorParamsToolbar) .editorParamsToolbarContainer) .editorParamsSlider)::-webkit-slider-thumb,:is(:is(:is(.toolbarButtonWithContainer .editorParamsToolbar) .editorParamsToolbarContainer) .editorParamsSlider)::-moz-range-thumb{ - background-color:white; - } - -#secondaryToolbar{ - height:auto; - width:220px; - position:absolute; - z-index:30000; - cursor:default; - min-height:26px; - max-height:calc(var(--viewer-container-height) - 40px); +.toolbarButton::before { + opacity: var(--toolbar-icon-opacity); + display: inline-block; + width: var(--icon-size); + height: var(--icon-size); + content: ''; + background-color: var(--toolbar-icon-bg-color); + -webkit-mask-size: cover; + mask-size: cover; + -webkit-mask-position: center; + mask-position: center; } -:is(#secondaryToolbar #secondaryToolbarButtonContainer) #secondaryOpenFile::before{ - -webkit-mask-image:var(--toolbarButton-openFile-icon); - mask-image:var(--toolbarButton-openFile-icon); - } - -:is(#secondaryToolbar #secondaryToolbarButtonContainer) #secondaryPrint::before{ - -webkit-mask-image:var(--toolbarButton-print-icon); - mask-image:var(--toolbarButton-print-icon); - } - -:is(#secondaryToolbar #secondaryToolbarButtonContainer) #secondaryDownload::before{ - -webkit-mask-image:var(--toolbarButton-download-icon); - mask-image:var(--toolbarButton-download-icon); - } - -:is(#secondaryToolbar #secondaryToolbarButtonContainer) #presentationMode::before{ - -webkit-mask-image:var(--toolbarButton-presentationMode-icon); - mask-image:var(--toolbarButton-presentationMode-icon); - } - -:is(#secondaryToolbar #secondaryToolbarButtonContainer) #viewBookmark::before{ - -webkit-mask-image:var(--toolbarButton-bookmark-icon); - mask-image:var(--toolbarButton-bookmark-icon); - } - -:is(#secondaryToolbar #secondaryToolbarButtonContainer) #firstPage::before{ - -webkit-mask-image:var(--secondaryToolbarButton-firstPage-icon); - mask-image:var(--secondaryToolbarButton-firstPage-icon); - } - -:is(#secondaryToolbar #secondaryToolbarButtonContainer) #lastPage::before{ - -webkit-mask-image:var(--secondaryToolbarButton-lastPage-icon); - mask-image:var(--secondaryToolbarButton-lastPage-icon); - } - -:is(#secondaryToolbar #secondaryToolbarButtonContainer) #pageRotateCcw::before{ - -webkit-mask-image:var(--secondaryToolbarButton-rotateCcw-icon); - mask-image:var(--secondaryToolbarButton-rotateCcw-icon); - } - -:is(#secondaryToolbar #secondaryToolbarButtonContainer) #pageRotateCw::before{ - -webkit-mask-image:var(--secondaryToolbarButton-rotateCw-icon); - mask-image:var(--secondaryToolbarButton-rotateCw-icon); - } - -:is(#secondaryToolbar #secondaryToolbarButtonContainer) #cursorSelectTool::before{ - -webkit-mask-image:var(--secondaryToolbarButton-selectTool-icon); - mask-image:var(--secondaryToolbarButton-selectTool-icon); - } - -:is(#secondaryToolbar #secondaryToolbarButtonContainer) #cursorHandTool::before{ - -webkit-mask-image:var(--secondaryToolbarButton-handTool-icon); - mask-image:var(--secondaryToolbarButton-handTool-icon); - } - -:is(#secondaryToolbar #secondaryToolbarButtonContainer) #scrollPage::before{ - -webkit-mask-image:var(--secondaryToolbarButton-scrollPage-icon); - mask-image:var(--secondaryToolbarButton-scrollPage-icon); - } - -:is(#secondaryToolbar #secondaryToolbarButtonContainer) #scrollVertical::before{ - -webkit-mask-image:var(--secondaryToolbarButton-scrollVertical-icon); - mask-image:var(--secondaryToolbarButton-scrollVertical-icon); - } - -:is(#secondaryToolbar #secondaryToolbarButtonContainer) #scrollHorizontal::before{ - -webkit-mask-image:var(--secondaryToolbarButton-scrollHorizontal-icon); - mask-image:var(--secondaryToolbarButton-scrollHorizontal-icon); - } - -:is(#secondaryToolbar #secondaryToolbarButtonContainer) #scrollWrapped::before{ - -webkit-mask-image:var(--secondaryToolbarButton-scrollWrapped-icon); - mask-image:var(--secondaryToolbarButton-scrollWrapped-icon); - } - -:is(#secondaryToolbar #secondaryToolbarButtonContainer) #spreadNone::before{ - -webkit-mask-image:var(--secondaryToolbarButton-spreadNone-icon); - mask-image:var(--secondaryToolbarButton-spreadNone-icon); - } - -:is(#secondaryToolbar #secondaryToolbarButtonContainer) #spreadOdd::before{ - -webkit-mask-image:var(--secondaryToolbarButton-spreadOdd-icon); - mask-image:var(--secondaryToolbarButton-spreadOdd-icon); - } - -:is(#secondaryToolbar #secondaryToolbarButtonContainer) #spreadEven::before{ - -webkit-mask-image:var(--secondaryToolbarButton-spreadEven-icon); - mask-image:var(--secondaryToolbarButton-spreadEven-icon); - } - -:is(#secondaryToolbar #secondaryToolbarButtonContainer) #imageAltTextSettings::before{ - -webkit-mask-image:var(--secondaryToolbarButton-imageAltTextSettings-icon); - mask-image:var(--secondaryToolbarButton-imageAltTextSettings-icon); - } - -:is(#secondaryToolbar #secondaryToolbarButtonContainer) #documentProperties::before{ - -webkit-mask-image:var(--secondaryToolbarButton-documentProperties-icon); - mask-image:var(--secondaryToolbarButton-documentProperties-icon); - } - -#findbar{ - --input-horizontal-padding:4px; - --findbar-padding:2px; - - width:-moz-max-content; - - width:max-content; - max-width:90vw; - min-height:var(--toolbar-height); - height:auto; - position:absolute; - z-index:30000; - cursor:default; - padding:0; - min-width:300px; - background-color:var(--toolbar-bg-color); - box-sizing:border-box; - flex-wrap:wrap; - justify-content:flex-start; +.toolbarButton.toggled { + background-color: var(--toggled-btn-bg-color); + color: var(--toggled-btn-color); } -#findbar > *{ - height:var(--toolbar-height); - padding:var(--findbar-padding); - } - -#findbar #findInputContainer{ - margin-inline-start:2px; - } - -:is(#findbar #findInputContainer) #findPreviousButton::before{ - -webkit-mask-image:var(--findbarButton-previous-icon); - mask-image:var(--findbarButton-previous-icon); - } - -:is(#findbar #findInputContainer) #findNextButton::before{ - -webkit-mask-image:var(--findbarButton-next-icon); - mask-image:var(--findbarButton-next-icon); - } - -:is(#findbar #findInputContainer) #findInput{ - width:200px; - padding:5px var(--input-horizontal-padding); - } - -:is(:is(#findbar #findInputContainer) #findInput)::-moz-placeholder{ - font-style:normal; - } - -:is(:is(#findbar #findInputContainer) #findInput)::placeholder{ - font-style:normal; - } - -.loadingInput:has( > [data-status="pending"]:is(:is(#findbar #findInputContainer) #findInput))::after{ - display:inline; - visibility:visible; - inset-inline-end:calc(var(--input-horizontal-padding) + 1px); - } - -[data-status="notFound"]:is(:is(#findbar #findInputContainer) #findInput){ - background-color:rgb(255 102 102); - } - -#findbar #findbarMessageContainer{ - display:none; - gap:4px; - } - -:is(#findbar #findbarMessageContainer):has( > :is(#findResultsCount,#findMsg):not(:empty)){ - display:inline flex; - } - -:is(#findbar #findbarMessageContainer) #findResultsCount{ - background-color:rgb(217 217 217); - color:rgb(82 82 82); - padding-block:4px; - } - -:is(:is(#findbar #findbarMessageContainer) #findResultsCount):empty{ - display:none; - } - -[data-status="notFound"]:is(:is(#findbar #findbarMessageContainer) #findMsg){ - font-weight:bold; - } - -:is(:is(#findbar #findbarMessageContainer) #findMsg):empty{ - display:none; - } - -#findbar.wrapContainers{ - flex-direction:column; - align-items:flex-start; - height:-moz-max-content; - height:max-content; - } - -#findbar.wrapContainers .toolbarLabel{ - margin:0 4px; - } - -#findbar.wrapContainers #findbarMessageContainer{ - flex-wrap:wrap; - flex-flow:column nowrap; - align-items:flex-start; - height:-moz-max-content; - height:max-content; - } - -:is(#findbar.wrapContainers #findbarMessageContainer) #findResultsCount{ - height:calc(var(--toolbar-height) - 2 * var(--findbar-padding)); - } - -:is(#findbar.wrapContainers #findbarMessageContainer) #findMsg{ - min-height:var(--toolbar-height); - } - -@page{ - margin:0; +.toolbarButton.toggled::before { + background-color: var(--toggled-btn-color); } -#printContainer{ - display:none; +.toolbarButton.toggled:hover { + outline: var(--toggled-hover-btn-outline) !important; } -@media print{ - body{ - background:rgb(0 0 0 / 0) none; +.toolbarButton.toggled:hover:active { + background-color: var(--toggled-hover-active-btn-color); +} + +.toolbarButton:is(:hover, :focus-visible) { + background-color: var(--button-hover-color); +} + +.toolbarButton:is(:hover, :focus-visible)::before { + background-color: var(--toolbar-icon-hover-bg-color); +} + +.toolbarButton:is([disabled='disabled'], [disabled]) { + opacity: 0.5; + pointer-events: none; +} + +.toolbarButton.labeled { + width: 100%; + min-height: var(--menuitem-height); + justify-content: flex-start; + gap: 8px; + padding-inline-start: 12px; + aspect-ratio: unset; + text-align: start; + white-space: normal; + cursor: default; +} + +.toolbarButton.labeled:is(a) { + text-decoration: none; +} + +.toolbarButton.labeled[href='#']:is(a) { + opacity: 0.5; + pointer-events: none; +} + +.toolbarButton.labeled::before { + opacity: var(--doorhanger-icon-opacity); +} + +.toolbarButton.labeled:is(:hover, :focus-visible) { + color: var(--doorhanger-hover-color); +} + +.toolbarButton.labeled > span { + display: inline-block; + width: -moz-max-content; + width: max-content; + height: auto; +} + +.toolbarButtonWithContainer { + height: 100%; + aspect-ratio: 1; + display: inline-block; + position: relative; + flex: none; +} + +.toolbarButtonWithContainer > .toolbarButton { + width: 100%; + height: 100%; +} + +.toolbarButtonWithContainer .menu { + padding-block: 5px; +} + +.toolbarButtonWithContainer .menuContainer { + height: auto; + max-height: calc( + var(--viewer-container-height) - var(--toolbar-height) - + var(--doorhanger-height) + ); + display: flex; + flex-direction: column; + box-sizing: border-box; + overflow-y: auto; +} + +.toolbarButtonWithContainer .editorParamsToolbar { + --editor-toolbar-min-width: 220px; + + height: auto; + min-width: var(--editor-toolbar-min-width); + width: -moz-max-content; + width: max-content; + position: absolute; + z-index: 30000; + cursor: default; +} + +:is(.toolbarButtonWithContainer .editorParamsToolbar) + :is(#editorStampAddImage, #editorSignatureAddSignature)::before { + -webkit-mask-image: var(--editorParams-stampAddImage-icon); + mask-image: var(--editorParams-stampAddImage-icon); +} + +:is(.toolbarButtonWithContainer .editorParamsToolbar) .editorParamsLabel { + flex: none; + font: menu; + font-size: 13px; + font-style: normal; + font-weight: 400; + line-height: 150%; + width: -moz-fit-content; + width: fit-content; + inset-inline-start: 0; + color: var(--main-color); +} + +:is(.toolbarButtonWithContainer .editorParamsToolbar) + button:is(:hover, :focus-visible) + .editorParamsLabel { + color: var(--doorhanger-hover-color); +} + +:is(.toolbarButtonWithContainer .editorParamsToolbar) + .editorParamsToolbarContainer { + width: 100%; + height: auto; + display: flex; + flex-direction: column; + box-sizing: border-box; + padding-inline: 10px; + padding-block: 10px; +} + +:is( + :is(.toolbarButtonWithContainer .editorParamsToolbar) + .editorParamsToolbarContainer + ) + > .editorParamsSetter { + min-height: 26px; + display: flex; + align-items: center; + justify-content: space-between; +} + +:is( + :is(.toolbarButtonWithContainer .editorParamsToolbar) + .editorParamsToolbarContainer + ) + .editorParamsColor { + width: 32px; + height: 32px; + flex: none; + padding: 0; +} + +:is( + :is(.toolbarButtonWithContainer .editorParamsToolbar) + .editorParamsToolbarContainer + ) + .editorParamsSlider { + background-color: transparent; + width: 90px; + flex: 0 1 0; + font: message-box; +} + +:is( + :is( + :is(.toolbarButtonWithContainer .editorParamsToolbar) + .editorParamsToolbarContainer + ) + .editorParamsSlider +)::-moz-range-progress { + background-color: black; +} + +:is( + :is( + :is(.toolbarButtonWithContainer .editorParamsToolbar) + .editorParamsToolbarContainer + ) + .editorParamsSlider +)::-webkit-slider-runnable-track, +:is( + :is( + :is(.toolbarButtonWithContainer .editorParamsToolbar) + .editorParamsToolbarContainer + ) + .editorParamsSlider +)::-moz-range-track { + background-color: black; +} + +:is( + :is( + :is(.toolbarButtonWithContainer .editorParamsToolbar) + .editorParamsToolbarContainer + ) + .editorParamsSlider +)::-webkit-slider-thumb, +:is( + :is( + :is(.toolbarButtonWithContainer .editorParamsToolbar) + .editorParamsToolbarContainer + ) + .editorParamsSlider +)::-moz-range-thumb { + background-color: white; +} + +#secondaryToolbar { + height: auto; + width: 220px; + position: absolute; + z-index: 30000; + cursor: default; + min-height: 26px; + max-height: calc(var(--viewer-container-height) - 40px); +} + +:is(#secondaryToolbar #secondaryToolbarButtonContainer) + #secondaryOpenFile::before { + -webkit-mask-image: var(--toolbarButton-openFile-icon); + mask-image: var(--toolbarButton-openFile-icon); +} + +:is(#secondaryToolbar #secondaryToolbarButtonContainer) + #secondaryPrint::before { + -webkit-mask-image: var(--toolbarButton-print-icon); + mask-image: var(--toolbarButton-print-icon); +} + +:is(#secondaryToolbar #secondaryToolbarButtonContainer) + #secondaryDownload::before { + -webkit-mask-image: var(--toolbarButton-download-icon); + mask-image: var(--toolbarButton-download-icon); +} + +:is(#secondaryToolbar #secondaryToolbarButtonContainer) + #presentationMode::before { + -webkit-mask-image: var(--toolbarButton-presentationMode-icon); + mask-image: var(--toolbarButton-presentationMode-icon); +} + +:is(#secondaryToolbar #secondaryToolbarButtonContainer) #viewBookmark::before { + -webkit-mask-image: var(--toolbarButton-bookmark-icon); + mask-image: var(--toolbarButton-bookmark-icon); +} + +:is(#secondaryToolbar #secondaryToolbarButtonContainer) #firstPage::before { + -webkit-mask-image: var(--secondaryToolbarButton-firstPage-icon); + mask-image: var(--secondaryToolbarButton-firstPage-icon); +} + +:is(#secondaryToolbar #secondaryToolbarButtonContainer) #lastPage::before { + -webkit-mask-image: var(--secondaryToolbarButton-lastPage-icon); + mask-image: var(--secondaryToolbarButton-lastPage-icon); +} + +:is(#secondaryToolbar #secondaryToolbarButtonContainer) #pageRotateCcw::before { + -webkit-mask-image: var(--secondaryToolbarButton-rotateCcw-icon); + mask-image: var(--secondaryToolbarButton-rotateCcw-icon); +} + +:is(#secondaryToolbar #secondaryToolbarButtonContainer) #pageRotateCw::before { + -webkit-mask-image: var(--secondaryToolbarButton-rotateCw-icon); + mask-image: var(--secondaryToolbarButton-rotateCw-icon); +} + +:is(#secondaryToolbar #secondaryToolbarButtonContainer) + #cursorSelectTool::before { + -webkit-mask-image: var(--secondaryToolbarButton-selectTool-icon); + mask-image: var(--secondaryToolbarButton-selectTool-icon); +} + +:is(#secondaryToolbar #secondaryToolbarButtonContainer) + #cursorHandTool::before { + -webkit-mask-image: var(--secondaryToolbarButton-handTool-icon); + mask-image: var(--secondaryToolbarButton-handTool-icon); +} + +:is(#secondaryToolbar #secondaryToolbarButtonContainer) #scrollPage::before { + -webkit-mask-image: var(--secondaryToolbarButton-scrollPage-icon); + mask-image: var(--secondaryToolbarButton-scrollPage-icon); +} + +:is(#secondaryToolbar #secondaryToolbarButtonContainer) + #scrollVertical::before { + -webkit-mask-image: var(--secondaryToolbarButton-scrollVertical-icon); + mask-image: var(--secondaryToolbarButton-scrollVertical-icon); +} + +:is(#secondaryToolbar #secondaryToolbarButtonContainer) + #scrollHorizontal::before { + -webkit-mask-image: var(--secondaryToolbarButton-scrollHorizontal-icon); + mask-image: var(--secondaryToolbarButton-scrollHorizontal-icon); +} + +:is(#secondaryToolbar #secondaryToolbarButtonContainer) #scrollWrapped::before { + -webkit-mask-image: var(--secondaryToolbarButton-scrollWrapped-icon); + mask-image: var(--secondaryToolbarButton-scrollWrapped-icon); +} + +:is(#secondaryToolbar #secondaryToolbarButtonContainer) #spreadNone::before { + -webkit-mask-image: var(--secondaryToolbarButton-spreadNone-icon); + mask-image: var(--secondaryToolbarButton-spreadNone-icon); +} + +:is(#secondaryToolbar #secondaryToolbarButtonContainer) #spreadOdd::before { + -webkit-mask-image: var(--secondaryToolbarButton-spreadOdd-icon); + mask-image: var(--secondaryToolbarButton-spreadOdd-icon); +} + +:is(#secondaryToolbar #secondaryToolbarButtonContainer) #spreadEven::before { + -webkit-mask-image: var(--secondaryToolbarButton-spreadEven-icon); + mask-image: var(--secondaryToolbarButton-spreadEven-icon); +} + +:is(#secondaryToolbar #secondaryToolbarButtonContainer) + #imageAltTextSettings::before { + -webkit-mask-image: var(--secondaryToolbarButton-imageAltTextSettings-icon); + mask-image: var(--secondaryToolbarButton-imageAltTextSettings-icon); +} + +:is(#secondaryToolbar #secondaryToolbarButtonContainer) + #documentProperties::before { + -webkit-mask-image: var(--secondaryToolbarButton-documentProperties-icon); + mask-image: var(--secondaryToolbarButton-documentProperties-icon); +} + +#findbar { + --input-horizontal-padding: 4px; + --findbar-padding: 2px; + + width: -moz-max-content; + + width: max-content; + max-width: 90vw; + min-height: var(--toolbar-height); + height: auto; + position: absolute; + z-index: 30000; + cursor: default; + padding: 0; + min-width: 300px; + background-color: var(--toolbar-bg-color); + box-sizing: border-box; + flex-wrap: wrap; + justify-content: flex-start; +} + +#findbar > * { + height: var(--toolbar-height); + padding: var(--findbar-padding); +} + +#findbar #findInputContainer { + margin-inline-start: 2px; +} + +:is(#findbar #findInputContainer) #findPreviousButton::before { + -webkit-mask-image: var(--findbarButton-previous-icon); + mask-image: var(--findbarButton-previous-icon); +} + +:is(#findbar #findInputContainer) #findNextButton::before { + -webkit-mask-image: var(--findbarButton-next-icon); + mask-image: var(--findbarButton-next-icon); +} + +:is(#findbar #findInputContainer) #findInput { + width: 200px; + padding: 5px var(--input-horizontal-padding); +} + +:is(:is(#findbar #findInputContainer) #findInput)::-moz-placeholder { + font-style: normal; +} + +:is(:is(#findbar #findInputContainer) #findInput)::placeholder { + font-style: normal; +} + +.loadingInput:has( + > [data-status='pending']:is(:is(#findbar #findInputContainer) #findInput) + )::after { + display: inline; + visibility: visible; + inset-inline-end: calc(var(--input-horizontal-padding) + 1px); +} + +[data-status='notFound']:is(:is(#findbar #findInputContainer) #findInput) { + background-color: rgb(255 102 102); +} + +#findbar #findbarMessageContainer { + display: none; + gap: 4px; +} + +:is(#findbar #findbarMessageContainer):has( + > :is(#findResultsCount, #findMsg):not(:empty) +) { + display: inline flex; +} + +:is(#findbar #findbarMessageContainer) #findResultsCount { + background-color: rgb(217 217 217); + color: rgb(82 82 82); + padding-block: 4px; +} + +:is(:is(#findbar #findbarMessageContainer) #findResultsCount):empty { + display: none; +} + +[data-status='notFound']:is(:is(#findbar #findbarMessageContainer) #findMsg) { + font-weight: bold; +} + +:is(:is(#findbar #findbarMessageContainer) #findMsg):empty { + display: none; +} + +#findbar.wrapContainers { + flex-direction: column; + align-items: flex-start; + height: -moz-max-content; + height: max-content; +} + +#findbar.wrapContainers .toolbarLabel { + margin: 0 4px; +} + +#findbar.wrapContainers #findbarMessageContainer { + flex-wrap: wrap; + flex-flow: column nowrap; + align-items: flex-start; + height: -moz-max-content; + height: max-content; +} + +:is(#findbar.wrapContainers #findbarMessageContainer) #findResultsCount { + height: calc(var(--toolbar-height) - 2 * var(--findbar-padding)); +} + +:is(#findbar.wrapContainers #findbarMessageContainer) #findMsg { + min-height: var(--toolbar-height); +} + +@page { + margin: 0; +} + +#printContainer { + display: none; +} + +@media print { + body { + background: rgb(0 0 0 / 0) none; } - body[data-pdfjsprinting] #outerContainer{ - display:none; + body[data-pdfjsprinting] #outerContainer { + display: none; } - body[data-pdfjsprinting] #printContainer{ - display:block; + body[data-pdfjsprinting] #printContainer { + display: block; } - #printContainer{ - height:100%; + #printContainer { + height: 100%; } - #printContainer > .printedPage{ - page-break-after:always; - page-break-inside:avoid; - height:100%; - width:100%; + #printContainer > .printedPage { + page-break-after: always; + page-break-inside: avoid; + height: 100%; + width: 100%; - display:flex; - flex-direction:column; - justify-content:center; - align-items:center; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; } - #printContainer > .xfaPrintedPage .xfaPage{ - position:absolute; + #printContainer > .xfaPrintedPage .xfaPage { + position: absolute; } - #printContainer > .xfaPrintedPage{ - page-break-after:always; - page-break-inside:avoid; - width:100%; - height:100%; - position:relative; + #printContainer > .xfaPrintedPage { + page-break-after: always; + page-break-inside: avoid; + width: 100%; + height: 100%; + position: relative; } - #printContainer > .printedPage :is(canvas, img){ - max-width:100%; - max-height:100%; + #printContainer > .printedPage :is(canvas, img) { + max-width: 100%; + max-height: 100%; - direction:ltr; - display:block; + direction: ltr; + display: block; } } -.visibleMediumView{ - display:none !important; +.visibleMediumView { + display: none !important; } -.toolbarLabel{ - width:-moz-max-content; - width:max-content; - min-width:16px; - height:100%; - padding-inline:4px; - margin:2px; - border-radius:2px; - color:var(--main-color); - font-size:12px; - line-height:14px; - text-align:left; - -webkit-user-select:none; - -moz-user-select:none; - user-select:none; - cursor:default; - box-sizing:border-box; +.toolbarLabel { + width: -moz-max-content; + width: max-content; + min-width: 16px; + height: 100%; + padding-inline: 4px; + margin: 2px; + border-radius: 2px; + color: var(--main-color); + font-size: 12px; + line-height: 14px; + text-align: left; + -webkit-user-select: none; + -moz-user-select: none; + user-select: none; + cursor: default; + box-sizing: border-box; - display:inline flex; - flex-direction:column; - align-items:center; - justify-content:center; + display: inline flex; + flex-direction: column; + align-items: center; + justify-content: center; } -.toolbarLabel > label{ - width:100%; - } - -.toolbarHorizontalGroup{ - height:100%; - display:inline flex; - flex-direction:row; - align-items:center; - justify-content:space-between; - gap:1px; - box-sizing:border-box; +.toolbarLabel > label { + width: 100%; } -.dropdownToolbarButton{ - display:inline flex; - flex-direction:row; - align-items:center; - justify-content:center; - position:relative; - - width:-moz-fit-content; - - width:fit-content; - min-width:140px; - padding:0; - background-color:var(--dropdown-btn-bg-color); - border:var(--dropdown-btn-border); - border-radius:2px; - color:var(--main-color); - font-size:12px; - line-height:14px; - -webkit-user-select:none; - -moz-user-select:none; - user-select:none; - cursor:default; - box-sizing:border-box; - outline:none; +.toolbarHorizontalGroup { + height: 100%; + display: inline flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + gap: 1px; + box-sizing: border-box; } -.dropdownToolbarButton:hover{ - background-color:var(--button-hover-color); - } +.dropdownToolbarButton { + display: inline flex; + flex-direction: row; + align-items: center; + justify-content: center; + position: relative; -.dropdownToolbarButton > select{ - -webkit-appearance:none; - -moz-appearance:none; - appearance:none; - width:inherit; - min-width:inherit; - height:28px; - font:message-box; - font-size:12px; - color:var(--main-color); - margin:0; - padding-block:1px 2px; - padding-inline:6px 38px; - border:none; - outline:none; - background-color:var(--dropdown-btn-bg-color); - } + width: -moz-fit-content; -:is(.dropdownToolbarButton > select) > option{ - background:var(--doorhanger-bg-color); - color:var(--main-color); - } - -:is(.dropdownToolbarButton > select):is(:hover,:focus-visible){ - background-color:var(--button-hover-color); - color:var(--toggled-btn-color); - } - -.dropdownToolbarButton::after{ - position:absolute; - display:inline; - width:var(--icon-size); - height:var(--icon-size); - - content:""; - background-color:var(--toolbar-icon-bg-color); - -webkit-mask-size:cover; - mask-size:cover; - - inset-inline-end:4px; - pointer-events:none; - -webkit-mask-image:var(--toolbarButton-menuArrow-icon); - mask-image:var(--toolbarButton-menuArrow-icon); - } - -.dropdownToolbarButton:is(:hover,:focus-visible,:active)::after{ - background-color:var(--toolbar-icon-hover-bg-color); - } - -#toolbarContainer{ - --menuitem-height:calc(var(--toolbar-height) - 6px); - - width:100%; - height:var(--toolbar-height); - padding:var(--toolbar-vertical-padding) var(--toolbar-horizontal-padding); - position:relative; - box-sizing:border-box; - font:message-box; - background-color:var(--toolbar-bg-color); - box-shadow:var(--toolbar-box-shadow); - border-bottom:var(--toolbar-border-bottom); + width: fit-content; + min-width: 140px; + padding: 0; + background-color: var(--dropdown-btn-bg-color); + border: var(--dropdown-btn-border); + border-radius: 2px; + color: var(--main-color); + font-size: 12px; + line-height: 14px; + -webkit-user-select: none; + -moz-user-select: none; + user-select: none; + cursor: default; + box-sizing: border-box; + outline: none; } -#toolbarContainer #toolbarViewer{ - width:100%; - height:100%; - justify-content:space-between; +.dropdownToolbarButton:hover { + background-color: var(--button-hover-color); +} + +.dropdownToolbarButton > select { + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + width: inherit; + min-width: inherit; + height: 28px; + font: message-box; + font-size: 12px; + color: var(--main-color); + margin: 0; + padding-block: 1px 2px; + padding-inline: 6px 38px; + border: none; + outline: none; + background-color: var(--dropdown-btn-bg-color); +} + +:is(.dropdownToolbarButton > select) > option { + background: var(--doorhanger-bg-color); + color: var(--main-color); +} + +:is(.dropdownToolbarButton > select):is(:hover, :focus-visible) { + background-color: var(--button-hover-color); + color: var(--toggled-btn-color); +} + +.dropdownToolbarButton::after { + position: absolute; + display: inline; + width: var(--icon-size); + height: var(--icon-size); + + content: ''; + background-color: var(--toolbar-icon-bg-color); + -webkit-mask-size: cover; + mask-size: cover; + + inset-inline-end: 4px; + pointer-events: none; + -webkit-mask-image: var(--toolbarButton-menuArrow-icon); + mask-image: var(--toolbarButton-menuArrow-icon); +} + +.dropdownToolbarButton:is(:hover, :focus-visible, :active)::after { + background-color: var(--toolbar-icon-hover-bg-color); +} + +#toolbarContainer { + --menuitem-height: calc(var(--toolbar-height) - 6px); + + width: 100%; + height: var(--toolbar-height); + padding: var(--toolbar-vertical-padding) var(--toolbar-horizontal-padding); + position: relative; + box-sizing: border-box; + font: message-box; + background-color: var(--toolbar-bg-color); + box-shadow: var(--toolbar-box-shadow); + border-bottom: var(--toolbar-border-bottom); +} + +#toolbarContainer #toolbarViewer { + width: 100%; + height: 100%; + justify-content: space-between; +} + +:is(#toolbarContainer #toolbarViewer) > * { + flex: none; +} + +:is(#toolbarContainer #toolbarViewer) input { + font: message-box; +} + +:is(#toolbarContainer #toolbarViewer) .toolbarButtonSpacer { + width: 30px; + display: block; + height: 1px; +} + +:is(#toolbarContainer #toolbarViewer) + #toolbarViewerLeft + #numPages.toolbarLabel { + padding-inline-start: 3px; + flex: none; +} + +#toolbarContainer #loadingBar { + --progressBar-percent: 0%; + --progressBar-end-offset: 0; + + position: absolute; + top: var(--toolbar-height); + inset-inline: 0 var(--progressBar-end-offset); + height: 4px; + background-color: var(--progressBar-bg-color); + border-bottom: 1px solid var(--toolbar-border-color); + transition-property: inset-inline-start; + transition-duration: var(--sidebar-transition-duration); + transition-timing-function: var(--sidebar-transition-timing-function); +} + +:is(#toolbarContainer #loadingBar) .progress { + position: absolute; + top: 0; + inset-inline-start: 0; + width: 100%; + transform: scaleX(var(--progressBar-percent)); + transform-origin: calc(50% - 50% * var(--dir-factor)) 0; + height: 100%; + background-color: var(--progressBar-color); + overflow: hidden; + transition: transform 200ms; +} + +.indeterminate:is(#toolbarContainer #loadingBar) .progress { + transform: none; + background-color: var(--progressBar-bg-color); + transition: none; +} + +:is(.indeterminate:is(#toolbarContainer #loadingBar) .progress) .glimmer { + position: absolute; + top: 0; + inset-inline-start: 0; + height: 100%; + width: calc(100% + 150px); + background: repeating-linear-gradient( + 135deg, + var(--progressBar-blend-color) 0, + var(--progressBar-bg-color) 5px, + var(--progressBar-bg-color) 45px, + var(--progressBar-color) 55px, + var(--progressBar-color) 95px, + var(--progressBar-blend-color) 100px + ); + animation: progressIndeterminate 1s linear infinite; +} + +@media all and (max-width: 840px) { + #sidebarContainer { + background-color: var(--sidebar-narrow-bg-color); } - -:is(#toolbarContainer #toolbarViewer) > *{ - flex:none; - } - -:is(#toolbarContainer #toolbarViewer) input{ - font:message-box; - } - -:is(#toolbarContainer #toolbarViewer) .toolbarButtonSpacer{ - width:30px; - display:block; - height:1px; - } - -:is(#toolbarContainer #toolbarViewer) #toolbarViewerLeft #numPages.toolbarLabel{ - padding-inline-start:3px; - flex:none; - } - -#toolbarContainer #loadingBar{ - --progressBar-percent:0%; - --progressBar-end-offset:0; - - position:absolute; - top:var(--toolbar-height); - inset-inline:0 var(--progressBar-end-offset); - height:4px; - background-color:var(--progressBar-bg-color); - border-bottom:1px solid var(--toolbar-border-color); - transition-property:inset-inline-start; - transition-duration:var(--sidebar-transition-duration); - transition-timing-function:var(--sidebar-transition-timing-function); - } - -:is(#toolbarContainer #loadingBar) .progress{ - position:absolute; - top:0; - inset-inline-start:0; - width:100%; - transform:scaleX(var(--progressBar-percent)); - transform-origin:calc(50% - 50% * var(--dir-factor)) 0; - height:100%; - background-color:var(--progressBar-color); - overflow:hidden; - transition:transform 200ms; - } - -.indeterminate:is(#toolbarContainer #loadingBar) .progress{ - transform:none; - background-color:var(--progressBar-bg-color); - transition:none; - } - -:is(.indeterminate:is(#toolbarContainer #loadingBar) .progress) .glimmer{ - position:absolute; - top:0; - inset-inline-start:0; - height:100%; - width:calc(100% + 150px); - background:repeating-linear-gradient( - 135deg, - var(--progressBar-blend-color) 0, - var(--progressBar-bg-color) 5px, - var(--progressBar-bg-color) 45px, - var(--progressBar-color) 55px, - var(--progressBar-color) 95px, - var(--progressBar-blend-color) 100px - ); - animation:progressIndeterminate 1s linear infinite; - } - -@media all and (max-width: 840px){ - #sidebarContainer{ - background-color:var(--sidebar-narrow-bg-color); - } - #outerContainer.sidebarOpen #viewerContainer{ - inset-inline-start:0 !important; + #outerContainer.sidebarOpen #viewerContainer { + inset-inline-start: 0 !important; } } -@media all and (max-width: 750px){ - #outerContainer .hiddenMediumView{ - display:none !important; +@media all and (max-width: 750px) { + #outerContainer .hiddenMediumView { + display: none !important; } - #outerContainer .visibleMediumView:not(.hidden, [hidden]){ - display:inline-block !important; + #outerContainer .visibleMediumView:not(.hidden, [hidden]) { + display: inline-block !important; } } -@media all and (max-width: 690px){ +@media all and (max-width: 690px) { .hiddenSmallView, - .hiddenSmallView *{ - display:none !important; + .hiddenSmallView * { + display: none !important; } - #toolbarContainer #toolbarViewer .toolbarButtonSpacer{ - width:0; + #toolbarContainer #toolbarViewer .toolbarButtonSpacer { + width: 0; } } -@media all and (max-width: 560px){ - #scaleSelectContainer{ - display:none; +@media all and (max-width: 560px) { + #scaleSelectContainer { + display: none; } } diff --git a/public/ghostscript-wasm/sRGB_IEC61966-2-1_no_black_scaling.icc b/public/sRGB_IEC61966-2-1_no_black_scaling.icc similarity index 100% rename from public/ghostscript-wasm/sRGB_IEC61966-2-1_no_black_scaling.icc rename to public/sRGB_IEC61966-2-1_no_black_scaling.icc diff --git a/public/site.webmanifest b/public/site.webmanifest index 99b26a4..5204c6b 100644 --- a/public/site.webmanifest +++ b/public/site.webmanifest @@ -36,10 +36,5 @@ "offline", "tools" ], - "screenshots": [], - "share_target": { - "action": "/", - "method": "GET", - "enctype": "application/x-www-form-urlencoded" - } + "screenshots": [] } \ No newline at end of file diff --git a/public/sitemap.xml b/public/sitemap.xml deleted file mode 100644 index 28fa343..0000000 --- a/public/sitemap.xml +++ /dev/null @@ -1,23962 +0,0 @@ - - - - https://www.bentopdf.com/de/404 - 2026-01-14 - weekly - 0.1 - - - - - - - - - - - - - - - https://www.bentopdf.com/404 - 2026-01-14 - weekly - 0.1 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/404 - 2026-01-14 - weekly - 0.1 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/404 - 2026-01-14 - weekly - 0.1 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/404 - 2026-01-14 - weekly - 0.1 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/404 - 2026-01-14 - weekly - 0.1 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/404 - 2026-01-14 - weekly - 0.1 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/404 - 2026-01-14 - weekly - 0.1 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/404 - 2026-01-14 - weekly - 0.1 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/404 - 2026-01-14 - weekly - 0.1 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/404 - 2026-01-14 - weekly - 0.1 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/about - 2026-01-14 - weekly - 0.8 - - - - - - - - - - - - - - - https://www.bentopdf.com/about - 2026-01-14 - weekly - 0.8 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/about - 2026-01-14 - weekly - 0.8 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/about - 2026-01-14 - weekly - 0.8 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/about - 2026-01-14 - weekly - 0.8 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/about - 2026-01-14 - weekly - 0.8 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/about - 2026-01-14 - weekly - 0.8 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/about - 2026-01-14 - weekly - 0.8 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/about - 2026-01-14 - weekly - 0.8 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/about - 2026-01-14 - weekly - 0.8 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/about - 2026-01-14 - weekly - 0.8 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/add-attachments - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/add-attachments - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/add-attachments - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/add-attachments - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/add-attachments - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/add-attachments - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/add-attachments - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/add-attachments - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/add-attachments - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/add-attachments - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/add-attachments - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/add-blank-page - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/add-blank-page - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/add-blank-page - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/add-blank-page - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/add-blank-page - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/add-blank-page - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/add-blank-page - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/add-blank-page - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/add-blank-page - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/add-blank-page - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/add-blank-page - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/add-stamps - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/add-stamps - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/add-stamps - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/add-stamps - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/add-stamps - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/add-stamps - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/add-stamps - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/add-stamps - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/add-stamps - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/add-stamps - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/add-stamps - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/add-watermark - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/add-watermark - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/add-watermark - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/add-watermark - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/add-watermark - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/add-watermark - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/add-watermark - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/add-watermark - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/add-watermark - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/add-watermark - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/add-watermark - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/alternate-merge - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/alternate-merge - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/alternate-merge - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/alternate-merge - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/alternate-merge - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/alternate-merge - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/alternate-merge - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/alternate-merge - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/alternate-merge - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/alternate-merge - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/alternate-merge - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/background-color - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/background-color - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/background-color - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/background-color - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/background-color - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/background-color - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/background-color - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/background-color - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/background-color - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/background-color - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/background-color - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/bmp-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/bmp-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/bmp-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/bmp-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/bmp-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/bmp-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/bmp-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/bmp-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/bmp-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/bmp-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/bmp-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/bookmark - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/bookmark - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/bookmark - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/bookmark - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/bookmark - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/bookmark - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/bookmark - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/bookmark - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/bookmark - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/bookmark - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/bookmark - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/cbz-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/cbz-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/cbz-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/cbz-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/cbz-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/cbz-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/cbz-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/cbz-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/cbz-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/cbz-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/cbz-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/change-permissions - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/change-permissions - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/change-permissions - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/change-permissions - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/change-permissions - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/change-permissions - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/change-permissions - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/change-permissions - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/change-permissions - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/change-permissions - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/change-permissions - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/combine-single-page - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/combine-single-page - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/combine-single-page - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/combine-single-page - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/combine-single-page - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/combine-single-page - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/combine-single-page - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/combine-single-page - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/combine-single-page - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/combine-single-page - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/combine-single-page - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/compare-pdfs - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/compare-pdfs - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/compare-pdfs - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/compare-pdfs - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/compare-pdfs - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/compare-pdfs - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/compare-pdfs - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/compare-pdfs - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/compare-pdfs - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/compare-pdfs - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/compare-pdfs - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/compress-pdf - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/compress-pdf - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/compress-pdf - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/compress-pdf - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/compress-pdf - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/compress-pdf - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/compress-pdf - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/compress-pdf - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/compress-pdf - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/compress-pdf - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/compress-pdf - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/contact - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/contact - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/contact - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/contact - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/contact - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/contact - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/contact - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/contact - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/contact - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/contact - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/contact - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/crop-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/crop-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/crop-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/crop-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/crop-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/crop-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/crop-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/crop-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/crop-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/crop-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/crop-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/csv-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/csv-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/csv-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/csv-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/csv-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/csv-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/csv-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/csv-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/csv-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/csv-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/csv-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/decrypt-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/decrypt-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/decrypt-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/decrypt-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/decrypt-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/decrypt-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/decrypt-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/decrypt-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/decrypt-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/decrypt-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/decrypt-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/delete-pages - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/delete-pages - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/delete-pages - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/delete-pages - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/delete-pages - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/delete-pages - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/delete-pages - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/delete-pages - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/delete-pages - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/delete-pages - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/delete-pages - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/deskew-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/deskew-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/deskew-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/deskew-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/deskew-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/deskew-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/deskew-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/deskew-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/deskew-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/deskew-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/deskew-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/digital-sign-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/digital-sign-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/digital-sign-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/digital-sign-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/digital-sign-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/digital-sign-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/digital-sign-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/digital-sign-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/digital-sign-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/digital-sign-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/digital-sign-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/divide-pages - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/divide-pages - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/divide-pages - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/divide-pages - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/divide-pages - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/divide-pages - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/divide-pages - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/divide-pages - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/divide-pages - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/divide-pages - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/divide-pages - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/edit-attachments - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/edit-attachments - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/edit-attachments - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/edit-attachments - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/edit-attachments - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/edit-attachments - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/edit-attachments - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/edit-attachments - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/edit-attachments - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/edit-attachments - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/edit-attachments - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/edit-metadata - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/edit-metadata - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/edit-metadata - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/edit-metadata - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/edit-metadata - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/edit-metadata - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/edit-metadata - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/edit-metadata - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/edit-metadata - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/edit-metadata - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/edit-metadata - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/edit-pdf - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/edit-pdf - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/edit-pdf - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/edit-pdf - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/edit-pdf - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/edit-pdf - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/edit-pdf - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/edit-pdf - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/edit-pdf - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/edit-pdf - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/edit-pdf - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/email-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/email-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/email-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/email-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/email-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/email-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/email-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/email-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/email-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/email-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/email-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/encrypt-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/encrypt-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/encrypt-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/encrypt-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/encrypt-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/encrypt-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/encrypt-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/encrypt-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/encrypt-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/encrypt-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/encrypt-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/epub-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/epub-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/epub-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/epub-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/epub-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/epub-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/epub-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/epub-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/epub-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/epub-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/epub-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/excel-to-pdf - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/excel-to-pdf - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/excel-to-pdf - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/excel-to-pdf - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/excel-to-pdf - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/excel-to-pdf - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/excel-to-pdf - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/excel-to-pdf - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/excel-to-pdf - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/excel-to-pdf - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/excel-to-pdf - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/extract-attachments - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/extract-attachments - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/extract-attachments - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/extract-attachments - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/extract-attachments - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/extract-attachments - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/extract-attachments - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/extract-attachments - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/extract-attachments - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/extract-attachments - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/extract-attachments - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/extract-images - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/extract-images - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/extract-images - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/extract-images - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/extract-images - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/extract-images - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/extract-images - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/extract-images - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/extract-images - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/extract-images - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/extract-images - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/extract-pages - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/extract-pages - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/extract-pages - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/extract-pages - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/extract-pages - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/extract-pages - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/extract-pages - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/extract-pages - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/extract-pages - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/extract-pages - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/extract-pages - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/extract-tables - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/extract-tables - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/extract-tables - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/extract-tables - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/extract-tables - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/extract-tables - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/extract-tables - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/extract-tables - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/extract-tables - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/extract-tables - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/extract-tables - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/faq - 2026-01-14 - weekly - 0.8 - - - - - - - - - - - - - - - https://www.bentopdf.com/faq - 2026-01-14 - weekly - 0.8 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/faq - 2026-01-14 - weekly - 0.8 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/faq - 2026-01-14 - weekly - 0.8 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/faq - 2026-01-14 - weekly - 0.8 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/faq - 2026-01-14 - weekly - 0.8 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/faq - 2026-01-14 - weekly - 0.8 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/faq - 2026-01-14 - weekly - 0.8 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/faq - 2026-01-14 - weekly - 0.8 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/faq - 2026-01-14 - weekly - 0.8 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/faq - 2026-01-14 - weekly - 0.8 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/fb2-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/fb2-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/fb2-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/fb2-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/fb2-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/fb2-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/fb2-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/fb2-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/fb2-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/fb2-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/fb2-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/fix-page-size - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/fix-page-size - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/fix-page-size - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/fix-page-size - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/fix-page-size - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/fix-page-size - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/fix-page-size - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/fix-page-size - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/fix-page-size - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/fix-page-size - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/fix-page-size - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/flatten-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/flatten-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/flatten-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/flatten-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/flatten-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/flatten-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/flatten-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/flatten-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/flatten-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/flatten-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/flatten-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/font-to-outline - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/font-to-outline - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/font-to-outline - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/font-to-outline - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/font-to-outline - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/font-to-outline - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/font-to-outline - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/font-to-outline - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/font-to-outline - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/font-to-outline - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/font-to-outline - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/form-creator - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/form-creator - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/form-creator - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/form-creator - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/form-creator - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/form-creator - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/form-creator - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/form-creator - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/form-creator - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/form-creator - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/form-creator - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/form-filler - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/form-filler - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/form-filler - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/form-filler - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/form-filler - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/form-filler - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/form-filler - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/form-filler - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/form-filler - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/form-filler - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/form-filler - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/header-footer - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/header-footer - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/header-footer - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/header-footer - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/header-footer - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/header-footer - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/header-footer - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/header-footer - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/header-footer - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/header-footer - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/header-footer - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/heic-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/heic-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/heic-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/heic-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/heic-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/heic-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/heic-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/heic-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/heic-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/heic-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/heic-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/image-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/image-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/image-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/image-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/image-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/image-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/image-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/image-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/image-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/image-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/image-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/de - 2026-01-14 - weekly - 1 - - - - - - - - - - - - - - - https://www.bentopdf.com - 2026-01-14 - weekly - 1 - - - - - - - - - - - - - - - https://www.bentopdf.com/es - 2026-01-14 - weekly - 1 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr - 2026-01-14 - weekly - 1 - - - - - - - - - - - - - - - https://www.bentopdf.com/id - 2026-01-14 - weekly - 1 - - - - - - - - - - - - - - - https://www.bentopdf.com/it - 2026-01-14 - weekly - 1 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt - 2026-01-14 - weekly - 1 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr - 2026-01-14 - weekly - 1 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi - 2026-01-14 - weekly - 1 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh - 2026-01-14 - weekly - 1 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW - 2026-01-14 - weekly - 1 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/invert-colors - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/invert-colors - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/invert-colors - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/invert-colors - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/invert-colors - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/invert-colors - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/invert-colors - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/invert-colors - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/invert-colors - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/invert-colors - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/invert-colors - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/jpg-to-pdf - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/jpg-to-pdf - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/jpg-to-pdf - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/jpg-to-pdf - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/jpg-to-pdf - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/jpg-to-pdf - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/jpg-to-pdf - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/jpg-to-pdf - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/jpg-to-pdf - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/jpg-to-pdf - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/jpg-to-pdf - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/json-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/json-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/json-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/json-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/json-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/json-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/json-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/json-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/json-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/json-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/json-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/licensing - 2026-01-14 - weekly - 0.5 - - - - - - - - - - - - - - - https://www.bentopdf.com/licensing - 2026-01-14 - weekly - 0.5 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/licensing - 2026-01-14 - weekly - 0.5 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/licensing - 2026-01-14 - weekly - 0.5 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/licensing - 2026-01-14 - weekly - 0.5 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/licensing - 2026-01-14 - weekly - 0.5 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/licensing - 2026-01-14 - weekly - 0.5 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/licensing - 2026-01-14 - weekly - 0.5 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/licensing - 2026-01-14 - weekly - 0.5 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/licensing - 2026-01-14 - weekly - 0.5 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/licensing - 2026-01-14 - weekly - 0.5 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/linearize-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/linearize-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/linearize-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/linearize-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/linearize-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/linearize-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/linearize-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/linearize-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/linearize-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/linearize-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/linearize-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/markdown-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/markdown-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/markdown-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/markdown-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/markdown-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/markdown-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/markdown-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/markdown-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/markdown-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/markdown-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/markdown-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/merge-pdf - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/merge-pdf - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/merge-pdf - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/merge-pdf - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/merge-pdf - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/merge-pdf - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/merge-pdf - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/merge-pdf - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/merge-pdf - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/merge-pdf - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/merge-pdf - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/mobi-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/mobi-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/mobi-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/mobi-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/mobi-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/mobi-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/mobi-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/mobi-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/mobi-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/mobi-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/mobi-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/n-up-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/n-up-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/n-up-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/n-up-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/n-up-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/n-up-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/n-up-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/n-up-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/n-up-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/n-up-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/n-up-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/ocr-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/ocr-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/ocr-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/ocr-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/ocr-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/ocr-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/ocr-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/ocr-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/ocr-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/ocr-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/ocr-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/odg-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/odg-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/odg-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/odg-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/odg-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/odg-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/odg-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/odg-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/odg-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/odg-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/odg-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/odp-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/odp-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/odp-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/odp-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/odp-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/odp-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/odp-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/odp-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/odp-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/odp-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/odp-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/ods-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/ods-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/ods-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/ods-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/ods-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/ods-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/ods-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/ods-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/ods-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/ods-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/ods-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/odt-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/odt-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/odt-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/odt-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/odt-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/odt-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/odt-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/odt-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/odt-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/odt-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/odt-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/organize-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/organize-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/organize-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/organize-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/organize-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/organize-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/organize-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/organize-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/organize-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/organize-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/organize-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/page-dimensions - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/page-dimensions - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/page-dimensions - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/page-dimensions - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/page-dimensions - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/page-dimensions - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/page-dimensions - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/page-dimensions - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/page-dimensions - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/page-dimensions - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/page-dimensions - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/page-numbers - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/page-numbers - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/page-numbers - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/page-numbers - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/page-numbers - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/page-numbers - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/page-numbers - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/page-numbers - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/page-numbers - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/page-numbers - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/page-numbers - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/pages-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pages-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/pages-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/pages-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/pages-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/pages-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/pages-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/pages-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/pages-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/pages-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/pages-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/pdf-booklet - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pdf-booklet - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/pdf-booklet - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/pdf-booklet - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/pdf-booklet - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/pdf-booklet - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/pdf-booklet - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/pdf-booklet - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/pdf-booklet - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/pdf-booklet - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/pdf-booklet - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/pdf-converter - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/pdf-converter - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/pdf-converter - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/pdf-converter - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/pdf-converter - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/pdf-converter - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/pdf-converter - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/pdf-converter - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/pdf-converter - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/pdf-converter - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/pdf-converter - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/pdf-editor - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/pdf-editor - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/pdf-editor - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/pdf-editor - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/pdf-editor - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/pdf-editor - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/pdf-editor - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/pdf-editor - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/pdf-editor - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/pdf-editor - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/pdf-editor - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/pdf-layers - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pdf-layers - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/pdf-layers - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/pdf-layers - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/pdf-layers - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/pdf-layers - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/pdf-layers - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/pdf-layers - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/pdf-layers - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/pdf-layers - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/pdf-layers - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/pdf-merge-split - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/pdf-merge-split - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/pdf-merge-split - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/pdf-merge-split - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/pdf-merge-split - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/pdf-merge-split - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/pdf-merge-split - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/pdf-merge-split - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/pdf-merge-split - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/pdf-merge-split - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/pdf-merge-split - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/pdf-multi-tool - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pdf-multi-tool - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/pdf-multi-tool - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/pdf-multi-tool - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/pdf-multi-tool - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/pdf-multi-tool - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/pdf-multi-tool - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/pdf-multi-tool - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/pdf-multi-tool - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/pdf-multi-tool - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/pdf-multi-tool - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/pdf-security - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/pdf-security - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/pdf-security - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/pdf-security - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/pdf-security - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/pdf-security - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/pdf-security - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/pdf-security - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/pdf-security - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/pdf-security - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/pdf-security - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/pdf-to-bmp - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pdf-to-bmp - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/pdf-to-bmp - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/pdf-to-bmp - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/pdf-to-bmp - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/pdf-to-bmp - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/pdf-to-bmp - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/pdf-to-bmp - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/pdf-to-bmp - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/pdf-to-bmp - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/pdf-to-bmp - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/pdf-to-csv - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pdf-to-csv - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/pdf-to-csv - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/pdf-to-csv - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/pdf-to-csv - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/pdf-to-csv - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/pdf-to-csv - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/pdf-to-csv - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/pdf-to-csv - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/pdf-to-csv - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/pdf-to-csv - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/pdf-to-docx - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/pdf-to-docx - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/pdf-to-docx - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/pdf-to-docx - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/pdf-to-docx - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/pdf-to-docx - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/pdf-to-docx - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/pdf-to-docx - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/pdf-to-docx - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/pdf-to-docx - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/pdf-to-docx - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/pdf-to-excel - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/pdf-to-excel - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/pdf-to-excel - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/pdf-to-excel - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/pdf-to-excel - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/pdf-to-excel - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/pdf-to-excel - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/pdf-to-excel - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/pdf-to-excel - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/pdf-to-excel - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/pdf-to-excel - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/pdf-to-greyscale - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pdf-to-greyscale - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/pdf-to-greyscale - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/pdf-to-greyscale - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/pdf-to-greyscale - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/pdf-to-greyscale - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/pdf-to-greyscale - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/pdf-to-greyscale - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/pdf-to-greyscale - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/pdf-to-greyscale - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/pdf-to-greyscale - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/pdf-to-jpg - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/pdf-to-jpg - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/pdf-to-jpg - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/pdf-to-jpg - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/pdf-to-jpg - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/pdf-to-jpg - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/pdf-to-jpg - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/pdf-to-jpg - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/pdf-to-jpg - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/pdf-to-jpg - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/pdf-to-jpg - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/pdf-to-json - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pdf-to-json - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/pdf-to-json - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/pdf-to-json - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/pdf-to-json - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/pdf-to-json - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/pdf-to-json - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/pdf-to-json - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/pdf-to-json - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/pdf-to-json - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/pdf-to-json - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/pdf-to-markdown - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pdf-to-markdown - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/pdf-to-markdown - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/pdf-to-markdown - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/pdf-to-markdown - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/pdf-to-markdown - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/pdf-to-markdown - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/pdf-to-markdown - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/pdf-to-markdown - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/pdf-to-markdown - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/pdf-to-markdown - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/pdf-to-pdfa - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pdf-to-pdfa - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/pdf-to-pdfa - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/pdf-to-pdfa - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/pdf-to-pdfa - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/pdf-to-pdfa - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/pdf-to-pdfa - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/pdf-to-pdfa - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/pdf-to-pdfa - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/pdf-to-pdfa - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/pdf-to-pdfa - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/pdf-to-png - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pdf-to-png - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/pdf-to-png - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/pdf-to-png - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/pdf-to-png - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/pdf-to-png - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/pdf-to-png - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/pdf-to-png - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/pdf-to-png - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/pdf-to-png - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/pdf-to-png - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/pdf-to-svg - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pdf-to-svg - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/pdf-to-svg - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/pdf-to-svg - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/pdf-to-svg - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/pdf-to-svg - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/pdf-to-svg - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/pdf-to-svg - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/pdf-to-svg - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/pdf-to-svg - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/pdf-to-svg - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/pdf-to-text - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pdf-to-text - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/pdf-to-text - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/pdf-to-text - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/pdf-to-text - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/pdf-to-text - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/pdf-to-text - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/pdf-to-text - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/pdf-to-text - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/pdf-to-text - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/pdf-to-text - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/pdf-to-tiff - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pdf-to-tiff - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/pdf-to-tiff - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/pdf-to-tiff - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/pdf-to-tiff - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/pdf-to-tiff - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/pdf-to-tiff - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/pdf-to-tiff - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/pdf-to-tiff - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/pdf-to-tiff - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/pdf-to-tiff - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/pdf-to-webp - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pdf-to-webp - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/pdf-to-webp - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/pdf-to-webp - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/pdf-to-webp - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/pdf-to-webp - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/pdf-to-webp - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/pdf-to-webp - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/pdf-to-webp - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/pdf-to-webp - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/pdf-to-webp - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/pdf-to-zip - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pdf-to-zip - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/pdf-to-zip - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/pdf-to-zip - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/pdf-to-zip - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/pdf-to-zip - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/pdf-to-zip - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/pdf-to-zip - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/pdf-to-zip - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/pdf-to-zip - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/pdf-to-zip - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/png-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/png-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/png-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/png-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/png-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/png-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/png-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/png-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/png-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/png-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/png-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/posterize-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/posterize-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/posterize-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/posterize-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/posterize-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/posterize-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/posterize-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/posterize-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/posterize-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/posterize-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/posterize-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/powerpoint-to-pdf - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/powerpoint-to-pdf - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/powerpoint-to-pdf - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/powerpoint-to-pdf - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/powerpoint-to-pdf - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/powerpoint-to-pdf - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/powerpoint-to-pdf - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/powerpoint-to-pdf - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/powerpoint-to-pdf - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/powerpoint-to-pdf - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/powerpoint-to-pdf - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/prepare-pdf-for-ai - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/prepare-pdf-for-ai - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/prepare-pdf-for-ai - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/prepare-pdf-for-ai - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/prepare-pdf-for-ai - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/prepare-pdf-for-ai - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/prepare-pdf-for-ai - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/prepare-pdf-for-ai - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/prepare-pdf-for-ai - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/prepare-pdf-for-ai - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/prepare-pdf-for-ai - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/privacy - 2026-01-14 - weekly - 0.5 - - - - - - - - - - - - - - - https://www.bentopdf.com/privacy - 2026-01-14 - weekly - 0.5 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/privacy - 2026-01-14 - weekly - 0.5 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/privacy - 2026-01-14 - weekly - 0.5 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/privacy - 2026-01-14 - weekly - 0.5 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/privacy - 2026-01-14 - weekly - 0.5 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/privacy - 2026-01-14 - weekly - 0.5 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/privacy - 2026-01-14 - weekly - 0.5 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/privacy - 2026-01-14 - weekly - 0.5 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/privacy - 2026-01-14 - weekly - 0.5 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/privacy - 2026-01-14 - weekly - 0.5 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/psd-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/psd-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/psd-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/psd-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/psd-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/psd-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/psd-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/psd-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/psd-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/psd-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/psd-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/pub-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pub-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/pub-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/pub-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/pub-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/pub-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/pub-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/pub-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/pub-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/pub-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/pub-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/rasterize-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/rasterize-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/rasterize-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/rasterize-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/rasterize-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/rasterize-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/rasterize-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/rasterize-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/rasterize-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/rasterize-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/rasterize-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/remove-annotations - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/remove-annotations - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/remove-annotations - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/remove-annotations - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/remove-annotations - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/remove-annotations - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/remove-annotations - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/remove-annotations - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/remove-annotations - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/remove-annotations - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/remove-annotations - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/remove-blank-pages - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/remove-blank-pages - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/remove-blank-pages - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/remove-blank-pages - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/remove-blank-pages - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/remove-blank-pages - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/remove-blank-pages - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/remove-blank-pages - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/remove-blank-pages - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/remove-blank-pages - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/remove-blank-pages - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/remove-metadata - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/remove-metadata - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/remove-metadata - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/remove-metadata - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/remove-metadata - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/remove-metadata - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/remove-metadata - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/remove-metadata - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/remove-metadata - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/remove-metadata - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/remove-metadata - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/remove-restrictions - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/remove-restrictions - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/remove-restrictions - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/remove-restrictions - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/remove-restrictions - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/remove-restrictions - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/remove-restrictions - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/remove-restrictions - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/remove-restrictions - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/remove-restrictions - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/remove-restrictions - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/repair-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/repair-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/repair-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/repair-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/repair-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/repair-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/repair-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/repair-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/repair-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/repair-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/repair-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/reverse-pages - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/reverse-pages - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/reverse-pages - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/reverse-pages - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/reverse-pages - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/reverse-pages - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/reverse-pages - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/reverse-pages - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/reverse-pages - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/reverse-pages - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/reverse-pages - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/rotate-custom - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/rotate-custom - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/rotate-custom - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/rotate-custom - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/rotate-custom - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/rotate-custom - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/rotate-custom - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/rotate-custom - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/rotate-custom - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/rotate-custom - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/rotate-custom - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/rotate-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/rotate-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/rotate-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/rotate-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/rotate-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/rotate-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/rotate-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/rotate-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/rotate-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/rotate-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/rotate-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/rtf-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/rtf-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/rtf-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/rtf-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/rtf-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/rtf-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/rtf-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/rtf-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/rtf-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/rtf-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/rtf-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/sanitize-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/sanitize-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/sanitize-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/sanitize-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/sanitize-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/sanitize-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/sanitize-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/sanitize-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/sanitize-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/sanitize-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/sanitize-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/sign-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/sign-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/sign-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/sign-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/sign-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/sign-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/sign-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/sign-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/sign-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/sign-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/sign-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/split-pdf - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/split-pdf - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/split-pdf - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/split-pdf - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/split-pdf - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/split-pdf - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/split-pdf - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/split-pdf - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/split-pdf - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/split-pdf - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/split-pdf - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/svg-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/svg-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/svg-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/svg-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/svg-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/svg-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/svg-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/svg-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/svg-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/svg-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/svg-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/table-of-contents - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/table-of-contents - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/table-of-contents - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/table-of-contents - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/table-of-contents - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/table-of-contents - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/table-of-contents - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/table-of-contents - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/table-of-contents - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/table-of-contents - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/table-of-contents - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/terms - 2026-01-14 - weekly - 0.5 - - - - - - - - - - - - - - - https://www.bentopdf.com/terms - 2026-01-14 - weekly - 0.5 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/terms - 2026-01-14 - weekly - 0.5 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/terms - 2026-01-14 - weekly - 0.5 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/terms - 2026-01-14 - weekly - 0.5 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/terms - 2026-01-14 - weekly - 0.5 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/terms - 2026-01-14 - weekly - 0.5 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/terms - 2026-01-14 - weekly - 0.5 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/terms - 2026-01-14 - weekly - 0.5 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/terms - 2026-01-14 - weekly - 0.5 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/terms - 2026-01-14 - weekly - 0.5 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/text-color - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/text-color - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/text-color - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/text-color - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/text-color - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/text-color - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/text-color - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/text-color - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/text-color - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/text-color - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/text-color - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/tiff-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/tiff-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/tiff-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/tiff-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/tiff-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/tiff-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/tiff-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/tiff-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/tiff-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/tiff-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/tiff-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/tools - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/tools - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/tools - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/tools - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/tools - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/tools - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/tools - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/tools - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/tools - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/tools - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/tools - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/txt-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/txt-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/txt-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/txt-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/txt-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/txt-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/txt-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/txt-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/txt-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/txt-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/txt-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/validate-signature-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/validate-signature-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/validate-signature-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/validate-signature-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/validate-signature-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/validate-signature-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/validate-signature-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/validate-signature-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/validate-signature-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/validate-signature-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/validate-signature-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/view-metadata - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/view-metadata - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/view-metadata - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/view-metadata - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/view-metadata - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/view-metadata - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/view-metadata - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/view-metadata - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/view-metadata - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/view-metadata - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/view-metadata - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/vsd-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/vsd-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/vsd-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/vsd-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/vsd-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/vsd-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/vsd-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/vsd-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/vsd-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/vsd-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/vsd-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/webp-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/webp-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/webp-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/webp-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/webp-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/webp-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/webp-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/webp-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/webp-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/webp-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/webp-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/word-to-pdf - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/word-to-pdf - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/word-to-pdf - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/word-to-pdf - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/word-to-pdf - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/word-to-pdf - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/word-to-pdf - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/word-to-pdf - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/word-to-pdf - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/word-to-pdf - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/word-to-pdf - 2026-01-14 - weekly - 0.9 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/wpd-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/wpd-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/wpd-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/wpd-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/wpd-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/wpd-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/wpd-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/wpd-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/wpd-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/wpd-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/wpd-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/wps-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/wps-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/wps-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/wps-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/wps-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/wps-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/wps-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/wps-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/wps-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/wps-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/wps-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/xml-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/xml-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/xml-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/xml-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/xml-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/xml-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/xml-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/xml-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/xml-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/xml-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/xml-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/de/xps-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/xps-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/es/xps-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/fr/xps-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/id/xps-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/it/xps-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/pt/xps-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/tr/xps-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/vi/xps-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh/xps-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - - https://www.bentopdf.com/zh-TW/xps-to-pdf - 2026-01-14 - weekly - 0.7 - - - - - - - - - - - - - - diff --git a/public/sw.js b/public/sw.js index bfb6c83..3956c8d 100644 --- a/public/sw.js +++ b/public/sw.js @@ -5,107 +5,105 @@ * Version: 1.1.0 */ -const CACHE_VERSION = 'bentopdf-v7'; +const CACHE_VERSION = 'bentopdf-v10'; const CACHE_NAME = `${CACHE_VERSION}-static`; - const getBasePath = () => { - const scope = self.registration?.scope || self.location.href; - const url = new URL(scope); - return url.pathname.replace(/\/$/, '') || ''; + const scope = self.registration?.scope || self.location.href; + const url = new URL(scope); + return url.pathname.replace(/\/$/, '') || ''; }; -const buildCriticalAssets = (basePath) => [ - `${basePath}/pymupdf-wasm/pyodide.js`, - `${basePath}/pymupdf-wasm/pyodide.asm.js`, - `${basePath}/pymupdf-wasm/pyodide.asm.wasm`, - `${basePath}/pymupdf-wasm/python_stdlib.zip`, - `${basePath}/pymupdf-wasm/pyodide-lock.json`, - - `${basePath}/pymupdf-wasm/pymupdf-1.26.3-cp313-none-pyodide_2025_0_wasm32.whl`, - `${basePath}/pymupdf-wasm/numpy-2.2.5-cp313-cp313-pyodide_2025_0_wasm32.whl`, - `${basePath}/pymupdf-wasm/opencv_python-4.11.0.86-cp313-cp313-pyodide_2025_0_wasm32.whl`, - `${basePath}/pymupdf-wasm/lxml-5.4.0-cp313-cp313-pyodide_2025_0_wasm32.whl`, - `${basePath}/pymupdf-wasm/python_docx-1.2.0-py3-none-any.whl`, - `${basePath}/pymupdf-wasm/pdf2docx-0.5.8-py3-none-any.whl`, - `${basePath}/pymupdf-wasm/fonttools-4.56.0-py3-none-any.whl`, - `${basePath}/pymupdf-wasm/typing_extensions-4.12.2-py3-none-any.whl`, - `${basePath}/pymupdf-wasm/pymupdf4llm-0.0.27-py3-none-any.whl`, - - `${basePath}/ghostscript-wasm/gs.js`, - `${basePath}/ghostscript-wasm/gs.wasm`, -]; +const buildCriticalAssets = () => []; self.addEventListener('install', (event) => { - const basePath = getBasePath(); - const CRITICAL_ASSETS = buildCriticalAssets(basePath); - // console.log('🚀 [ServiceWorker] Installing version:', CACHE_VERSION); - // console.log('📍 [ServiceWorker] Base path detected:', basePath || '/'); - // console.log('📦 [ServiceWorker] Will cache', CRITICAL_ASSETS.length, 'critical assets'); + const CRITICAL_ASSETS = buildCriticalAssets(); + // console.log('🚀 [ServiceWorker] Installing version:', CACHE_VERSION); + // console.log('📍 [ServiceWorker] Base path detected:', basePath || '/'); + // console.log('📦 [ServiceWorker] Will cache', CRITICAL_ASSETS.length, 'critical assets'); - event.waitUntil( - caches.open(CACHE_NAME) - .then((cache) => { - // console.log('[ServiceWorker] Caching critical assets...'); - return cacheInBatches(cache, CRITICAL_ASSETS, 5); - }) - .then(() => { - // console.log('✅ [ServiceWorker] All critical assets cached successfully!'); - // console.log('⏭️ [ServiceWorker] Skipping waiting, activating immediately...'); - return self.skipWaiting(); - }) - .catch((error) => { - console.error('[ServiceWorker] Cache installation failed:', error); - }) - ); + event.waitUntil( + caches + .open(CACHE_NAME) + .then((cache) => { + // console.log('[ServiceWorker] Caching critical assets...'); + return cacheInBatches(cache, CRITICAL_ASSETS, 5); + }) + .then(() => { + // console.log('✅ [ServiceWorker] All critical assets cached successfully!'); + // console.log('⏭️ [ServiceWorker] Skipping waiting, activating immediately...'); + return self.skipWaiting(); + }) + .catch((error) => { + console.error('[ServiceWorker] Cache installation failed:', error); + }) + ); }); self.addEventListener('activate', (event) => { - // console.log('🔄 [ServiceWorker] Activating version:', CACHE_VERSION); + // console.log('🔄 [ServiceWorker] Activating version:', CACHE_VERSION); - event.waitUntil( - caches.keys() - .then((cacheNames) => { - return Promise.all( - cacheNames.map((cacheName) => { - if (cacheName.startsWith('bentopdf-') && cacheName !== CACHE_NAME) { - // console.log('[ServiceWorker] Deleting old cache:', cacheName); - return caches.delete(cacheName); - } - }) - ); - }) - .then(() => { - // console.log('✅ [ServiceWorker] Activated successfully!'); - // console.log('🎯 [ServiceWorker] Taking control of all pages...'); - return self.clients.claim(); - }) - ); + event.waitUntil( + caches + .keys() + .then((cacheNames) => { + return Promise.all( + cacheNames.map((cacheName) => { + if (cacheName.startsWith('bentopdf-') && cacheName !== CACHE_NAME) { + // console.log('[ServiceWorker] Deleting old cache:', cacheName); + return caches.delete(cacheName); + } + }) + ); + }) + .then(() => { + // console.log('✅ [ServiceWorker] Activated successfully!'); + // console.log('🎯 [ServiceWorker] Taking control of all pages...'); + return self.clients.claim(); + }) + ); }); self.addEventListener('fetch', (event) => { - const url = new URL(event.request.url); + const url = new URL(event.request.url); - const isCDN = url.hostname === 'cdn.jsdelivr.net'; - const isLocal = url.origin === location.origin; + const isCDN = url.hostname === 'cdn.jsdelivr.net'; + const isLocal = url.origin === location.origin; - if (!isLocal && !isCDN) { - return; - } - if (isLocal && (url.searchParams.has('t') || url.searchParams.has('import') || url.searchParams.has('direct'))) { - // console.log('🔧 [Dev Mode] Skipping Vite HMR request:', url.pathname); - return; - } + if (!isLocal && !isCDN) { + return; + } + if ( + isLocal && + (url.searchParams.has('t') || + url.searchParams.has('import') || + url.searchParams.has('direct')) + ) { + // console.log('🔧 [Dev Mode] Skipping Vite HMR request:', url.pathname); + return; + } - if (isLocal && (url.pathname.includes('/@vite') || url.pathname.includes('/@id') || url.pathname.includes('/@fs'))) { - return; - } + if ( + isLocal && + (url.pathname.includes('/@vite') || + url.pathname.includes('/@id') || + url.pathname.includes('/@fs')) + ) { + return; + } - if (shouldCache(url.pathname, isCDN)) { - event.respondWith(cacheFirstStrategyWithDedup(event.request, isCDN)); - } else if (isLocal && (url.pathname.endsWith('.html') || url.pathname === '/')) { - event.respondWith(networkFirstStrategy(event.request)); - } + if (isLocal && url.pathname.includes('/locales/')) { + event.respondWith(networkFirstStrategy(event.request)); + } else if (shouldCache(url.pathname, isCDN)) { + event.respondWith(cacheFirstStrategyWithDedup(event.request, isCDN)); + } else if ( + isLocal && + (url.pathname.endsWith('.html') || + url.pathname === '/' || + /^\/(en|fr|es|de|zh|zh-TW|vi|tr|id|it|pt|nl|be)(\/|$)/.test(url.pathname)) + ) { + event.respondWith(networkFirstStrategy(event.request)); + } }); /** @@ -113,82 +111,121 @@ self.addEventListener('fetch', (event) => { * Ensures we only cache CDN OR local version, never both */ async function cacheFirstStrategyWithDedup(request, isCDN) { - const url = new URL(request.url); - const fileName = url.pathname.split('/').pop(); + const url = new URL(request.url); + const fileName = url.pathname.split('/').pop(); - try { - const cachedResponse = await findCachedFile(fileName); - if (cachedResponse) { - // console.log('⚡ [Cache HIT] Instant load:', fileName); - return cachedResponse; - } - - // console.log(`📥 [Cache MISS] Downloading from ${isCDN ? 'CDN' : 'local'}:`, fileName); - - const networkResponse = await fetch(request); - - if (networkResponse && networkResponse.status === 200) { - const cache = await caches.open(CACHE_NAME); - - await removeDuplicateCache(cache, fileName, isCDN); - - await cache.put(request, networkResponse.clone()); - // console.log(`💾 [Cached from ${isCDN ? 'CDN' : 'local'}] Saved:`, fileName); - } - - return networkResponse; - } catch (error) { - if (isCDN) { - console.warn(`⚠️ [CDN Failed] Trying local fallback for: ${fileName}`); - const basePath = getBasePath(); - const localPath = getLocalPathForCDNUrl(url.pathname); - - if (localPath) { - const localUrl = `${basePath}${localPath}${fileName}`; - try { - const fallbackResponse = await fetch(localUrl); - if (fallbackResponse && fallbackResponse.status === 200) { - const cache = await caches.open(CACHE_NAME); - await cache.put(localUrl, fallbackResponse.clone()); - // console.log('✅ [Fallback Success] Cached local version:', fileName); - return fallbackResponse; - } - } catch (fallbackError) { - console.error('[ServiceWorker] Both CDN and local failed for:', fileName); - } - } - } - throw error; + try { + const cachedResponse = await findCachedFile(fileName, request.url); + if (cachedResponse) { + // console.log('⚡ [Cache HIT] Instant load:', fileName); + return cachedResponse; } + + // console.log(`📥 [Cache MISS] Downloading from ${isCDN ? 'CDN' : 'local'}:`, fileName); + + const networkResponse = await fetch(request); + + if (networkResponse && networkResponse.status === 200) { + const clone = networkResponse.clone(); + const buffer = await clone.arrayBuffer(); + if (buffer.byteLength > 0) { + const cache = await caches.open(CACHE_NAME); + await removeDuplicateCache(cache, fileName, isCDN); + await cache.put( + request, + new Response(buffer, { + status: networkResponse.status, + statusText: networkResponse.statusText, + headers: networkResponse.headers, + }) + ); + } + } + + return networkResponse; + } catch (error) { + if (isCDN) { + console.warn(`⚠️ [CDN Failed] Trying local fallback for: ${fileName}`); + const basePath = getBasePath(); + const localPath = getLocalPathForCDNUrl(url.pathname); + + if (localPath) { + const localUrl = `${basePath}${localPath}${fileName}`; + try { + const fallbackResponse = await fetch(localUrl); + if (fallbackResponse && fallbackResponse.status === 200) { + const fbClone = fallbackResponse.clone(); + const fbBuffer = await fbClone.arrayBuffer(); + if (fbBuffer.byteLength > 0) { + const cache = await caches.open(CACHE_NAME); + await cache.put( + localUrl, + new Response(fbBuffer, { + status: fallbackResponse.status, + statusText: fallbackResponse.statusText, + headers: fallbackResponse.headers, + }) + ); + } + return fallbackResponse; + } + } catch (fallbackError) { + console.error( + '[ServiceWorker] Both CDN and local failed for:', + fileName + ); + } + } + } + throw error; + } } -async function findCachedFile(fileName) { - const cache = await caches.open(CACHE_NAME); - const requests = await cache.keys(); +async function findCachedFile(fileName, requestUrl) { + const cache = await caches.open(CACHE_NAME); - for (const req of requests) { - const reqUrl = new URL(req.url); - if (reqUrl.pathname.endsWith(fileName)) { - return await cache.match(req); - } + const exactMatch = await cache.match(requestUrl); + if (exactMatch) { + const clone = exactMatch.clone(); + const buffer = await clone.arrayBuffer(); + if (buffer.byteLength > 0) { + return exactMatch; } - return null; + await cache.delete(requestUrl); + } + + const requests = await cache.keys(); + for (const req of requests) { + const reqUrl = new URL(req.url); + if (reqUrl.pathname.endsWith(fileName)) { + const response = await cache.match(req); + if (response) { + const clone = response.clone(); + const buffer = await clone.arrayBuffer(); + if (buffer.byteLength > 0) { + return response; + } + await cache.delete(req); + } + } + } + return null; } async function removeDuplicateCache(cache, fileName, isCDN) { - const requests = await cache.keys(); + const requests = await cache.keys(); - for (const req of requests) { - const reqUrl = new URL(req.url); - if (reqUrl.pathname.endsWith(fileName)) { - // If caching CDN version, remove local version (and vice versa) - const reqIsCDN = reqUrl.hostname === 'cdn.jsdelivr.net'; - if (reqIsCDN !== isCDN) { - await cache.delete(req); - // console.log(`[Dedup] Removed ${reqIsCDN ? 'CDN' : 'local'} version of:`, fileName); - } - } + for (const req of requests) { + const reqUrl = new URL(req.url); + if (reqUrl.pathname.endsWith(fileName)) { + // If caching CDN version, remove local version (and vice versa) + const reqIsCDN = reqUrl.hostname === 'cdn.jsdelivr.net'; + if (reqIsCDN !== isCDN) { + await cache.delete(req); + // console.log(`[Dedup] Removed ${reqIsCDN ? 'CDN' : 'local'} version of:`, fileName); + } } + } } /** @@ -196,23 +233,34 @@ async function removeDuplicateCache(cache, fileName, isCDN) { * Perfect for HTML files that might update */ async function networkFirstStrategy(request) { - try { - const networkResponse = await fetch(request); + try { + const networkResponse = await fetch(request); - if (networkResponse && networkResponse.status === 200) { - const cache = await caches.open(CACHE_NAME); - cache.put(request, networkResponse.clone()); - } - - return networkResponse; - } catch (error) { - const cachedResponse = await caches.match(request); - if (cachedResponse) { - // console.log('[Offline Mode] Serving from cache:', request.url.split('/').pop()); - return cachedResponse; - } - throw error; + if (networkResponse && networkResponse.status === 200) { + const clone = networkResponse.clone(); + const buffer = await clone.arrayBuffer(); + if (buffer.byteLength > 0) { + const cache = await caches.open(CACHE_NAME); + cache.put( + request, + new Response(buffer, { + status: networkResponse.status, + statusText: networkResponse.statusText, + headers: networkResponse.headers, + }) + ); + } } + + return networkResponse; + } catch (error) { + const cachedResponse = await caches.match(request); + if (cachedResponse) { + // console.log('[Offline Mode] Serving from cache:', request.url.split('/').pop()); + return cachedResponse; + } + throw error; + } } /** @@ -220,16 +268,10 @@ async function networkFirstStrategy(request) { * Returns the local directory path for a given CDN package */ function getLocalPathForCDNUrl(pathname) { - if (pathname.includes('/@bentopdf/pymupdf-wasm')) { - return '/pymupdf-wasm/'; - } - if (pathname.includes('/@bentopdf/gs-wasm')) { - return '/ghostscript-wasm/'; - } - if (pathname.includes('/@matbee/libreoffice-converter')) { - return '/libreoffice-wasm/'; - } - return null; + if (pathname.includes('/@matbee/libreoffice-converter')) { + return '/libreoffice-wasm/'; + } + return null; } /** @@ -237,60 +279,63 @@ function getLocalPathForCDNUrl(pathname) { * Handles both local and CDN URLs */ function shouldCache(pathname, isCDN = false) { - if (isCDN) { - return ( - pathname.includes('/@bentopdf/pymupdf-wasm') || - pathname.includes('/@bentopdf/gs-wasm') || - pathname.includes('/@matbee/libreoffice-converter') || - pathname.match(/\.(wasm|whl|zip|json|js|gz)$/) - ); - } - + if (isCDN) { return ( - pathname.includes('/libreoffice-wasm/') || - pathname.includes('/pymupdf-wasm/') || - pathname.includes('/ghostscript-wasm/') || - pathname.includes('/embedpdf/') || - pathname.includes('/assets/') || - pathname.match(/\.(js|mjs|css|wasm|whl|zip|json|png|jpg|jpeg|gif|svg|woff|woff2|ttf|gz|br)$/) + pathname.includes('/@bentopdf/pymupdf-wasm') || + pathname.includes('/@bentopdf/gs-wasm') || + pathname.includes('/@matbee/libreoffice-converter') || + pathname.match(/\.(wasm|whl|zip|json|js|gz)$/) ); + } + + return ( + pathname.includes('/libreoffice-wasm/') || + pathname.includes('/embedpdf/') || + pathname.includes('/assets/') || + pathname.match( + /\.(js|mjs|css|wasm|whl|zip|json|png|jpg|jpeg|gif|svg|woff|woff2|ttf|gz|br)$/ + ) + ); } /** * Cache assets in batches to avoid overwhelming the browser */ async function cacheInBatches(cache, urls, batchSize = 5) { - for (let i = 0; i < urls.length; i += batchSize) { - const batch = urls.slice(i, i + batchSize); - // console.log(`[ServiceWorker] Caching batch ${Math.floor(i / batchSize) + 1}/${Math.ceil(urls.length / batchSize)}`); + for (let i = 0; i < urls.length; i += batchSize) { + const batch = urls.slice(i, i + batchSize); - await Promise.all( - batch.map(async (url) => { - try { - await cache.add(url); - const fileName = url.split('/').pop(); - const fileSize = fileName.includes('.wasm') || fileName.includes('.whl') ? '(large file)' : ''; - // console.log(` ✓ Cached: ${fileName} ${fileSize}`); - } catch (error) { - console.warn('[ServiceWorker] Failed to cache:', url, error.message); - } - }) - ); - } + await Promise.all( + batch.map(async (url) => { + try { + const response = await fetch(url); + if (response.ok && response.status === 200) { + const clone = response.clone(); + const buffer = await clone.arrayBuffer(); + if (buffer.byteLength > 0) { + await cache.put(url, response); + } + } + } catch (error) { + console.warn('[ServiceWorker] Failed to cache:', url, error.message); + } + }) + ); + } } self.addEventListener('message', (event) => { - if (event.data && event.data.type === 'SKIP_WAITING') { - self.skipWaiting(); - } + if (event.data && event.data.type === 'SKIP_WAITING') { + self.skipWaiting(); + } - if (event.data && event.data.type === 'CLEAR_CACHE') { - event.waitUntil( - caches.delete(CACHE_NAME).then(() => { - console.log('[ServiceWorker] Cache cleared'); - }) - ); - } + if (event.data && event.data.type === 'CLEAR_CACHE') { + event.waitUntil( + caches.delete(CACHE_NAME).then(() => { + console.log('[ServiceWorker] Cache cleared'); + }) + ); + } }); // console.log('🎉 [ServiceWorker] Script loaded successfully! Ready to cache assets.'); diff --git a/public/workers/add-attachments.worker.d.ts b/public/workers/add-attachments.worker.d.ts index 8c57b42..c47d803 100644 --- a/public/workers/add-attachments.worker.d.ts +++ b/public/workers/add-attachments.worker.d.ts @@ -5,6 +5,7 @@ interface AddAttachmentsMessage { pdfBuffer: ArrayBuffer; attachmentBuffers: ArrayBuffer[]; attachmentNames: string[]; + cpdfUrl?: string; } interface AddAttachmentsSuccessResponse { @@ -17,4 +18,6 @@ interface AddAttachmentsErrorResponse { message: string; } -type AddAttachmentsResponse = AddAttachmentsSuccessResponse | AddAttachmentsErrorResponse; +type AddAttachmentsResponse = + | AddAttachmentsSuccessResponse + | AddAttachmentsErrorResponse; diff --git a/public/workers/add-attachments.worker.js b/public/workers/add-attachments.worker.js index aba62b6..b6ba15a 100644 --- a/public/workers/add-attachments.worker.js +++ b/public/workers/add-attachments.worker.js @@ -1,13 +1,32 @@ -const baseUrl = self.location.href.substring(0, self.location.href.lastIndexOf('/workers/') + 1); -self.importScripts(baseUrl + 'coherentpdf.browser.min.js'); +let cpdfLoaded = false; + +function loadCpdf(cpdfUrl) { + if (cpdfLoaded) return Promise.resolve(); + + return new Promise((resolve, reject) => { + if (typeof coherentpdf !== 'undefined') { + cpdfLoaded = true; + resolve(); + return; + } + + try { + self.importScripts(cpdfUrl); + cpdfLoaded = true; + resolve(); + } catch (error) { + reject(new Error('Failed to load CoherentPDF: ' + error.message)); + } + }); +} function parsePageRange(rangeString, totalPages) { const pages = new Set(); - const parts = rangeString.split(',').map(s => s.trim()); + const parts = rangeString.split(',').map((s) => s.trim()); for (const part of parts) { if (part.includes('-')) { - const [start, end] = part.split('-').map(s => parseInt(s.trim(), 10)); + const [start, end] = part.split('-').map((s) => parseInt(s.trim(), 10)); if (isNaN(start) || isNaN(end)) continue; for (let i = Math.max(1, start); i <= Math.min(totalPages, end); i++) { pages.add(i); @@ -23,7 +42,13 @@ function parsePageRange(rangeString, totalPages) { return Array.from(pages).sort((a, b) => a - b); } -function addAttachmentsToPDFInWorker(pdfBuffer, attachmentBuffers, attachmentNames, attachmentLevel, pageRange) { +function addAttachmentsToPDFInWorker( + pdfBuffer, + attachmentBuffers, + attachmentNames, + attachmentLevel, + pageRange +) { try { const uint8Array = new Uint8Array(pdfBuffer); @@ -33,18 +58,21 @@ function addAttachmentsToPDFInWorker(pdfBuffer, attachmentBuffers, attachmentNam } catch (error) { const errorMsg = error.message || error.toString(); - if (errorMsg.includes('Failed to read PDF') || + if ( + errorMsg.includes('Failed to read PDF') || errorMsg.includes('Could not read object') || errorMsg.includes('No /Root entry') || - errorMsg.includes('PDFError')) { + errorMsg.includes('PDFError') + ) { self.postMessage({ status: 'error', - message: 'The PDF file has structural issues and cannot be processed. The file may be corrupted, incomplete, or created with non-standard tools. Please try:\n\n• Opening and re-saving the PDF in another PDF viewer\n• Using a different PDF file\n• Repairing the PDF with a PDF repair tool' + message: + 'The PDF file has structural issues and cannot be processed. The file may be corrupted, incomplete, or created with non-standard tools. Please try:\n\n• Opening and re-saving the PDF in another PDF viewer\n• Using a different PDF file\n• Repairing the PDF with a PDF repair tool', }); } else { self.postMessage({ status: 'error', - message: `Failed to load PDF: ${errorMsg}` + message: `Failed to load PDF: ${errorMsg}`, }); } return; @@ -57,7 +85,7 @@ function addAttachmentsToPDFInWorker(pdfBuffer, attachmentBuffers, attachmentNam if (!pageRange) { self.postMessage({ status: 'error', - message: 'Page range is required for page-level attachments.' + message: 'Page range is required for page-level attachments.', }); coherentpdf.deletePdf(pdf); return; @@ -66,7 +94,7 @@ function addAttachmentsToPDFInWorker(pdfBuffer, attachmentBuffers, attachmentNam if (targetPages.length === 0) { self.postMessage({ status: 'error', - message: 'Invalid page range specified.' + message: 'Invalid page range specified.', }); coherentpdf.deletePdf(pdf); return; @@ -82,21 +110,25 @@ function addAttachmentsToPDFInWorker(pdfBuffer, attachmentBuffers, attachmentNam coherentpdf.attachFileFromMemory(attachmentData, attachmentName, pdf); } else { for (const pageNum of targetPages) { - coherentpdf.attachFileToPageFromMemory(attachmentData, attachmentName, pdf, pageNum); + coherentpdf.attachFileToPageFromMemory( + attachmentData, + attachmentName, + pdf, + pageNum + ); } } } catch (error) { console.warn(`Failed to attach file ${attachmentNames[i]}:`, error); self.postMessage({ status: 'error', - message: `Failed to attach file ${attachmentNames[i]}: ${error.message || error}` + message: `Failed to attach file ${attachmentNames[i]}: ${error.message || error}`, }); coherentpdf.deletePdf(pdf); return; } } - // Save the modified PDF const modifiedBytes = coherentpdf.toMemory(pdf, false, false); coherentpdf.deletePdf(pdf); @@ -105,22 +137,46 @@ function addAttachmentsToPDFInWorker(pdfBuffer, attachmentBuffers, attachmentNam modifiedBytes.byteOffset + modifiedBytes.byteLength ); - self.postMessage({ - status: 'success', - modifiedPDF: buffer - }, [buffer]); - + self.postMessage( + { + status: 'success', + modifiedPDF: buffer, + }, + [buffer] + ); } catch (error) { self.postMessage({ status: 'error', - message: error instanceof Error - ? error.message - : 'Unknown error occurred while adding attachments.' + message: + error instanceof Error + ? error.message + : 'Unknown error occurred while adding attachments.', }); } } -self.onmessage = (e) => { +self.onmessage = async function (e) { + const { cpdfUrl } = e.data; + + if (!cpdfUrl) { + self.postMessage({ + status: 'error', + message: + 'CoherentPDF URL not provided. Please configure it in WASM Settings.', + }); + return; + } + + try { + await loadCpdf(cpdfUrl); + } catch (error) { + self.postMessage({ + status: 'error', + message: error.message, + }); + return; + } + if (e.data.command === 'add-attachments') { addAttachmentsToPDFInWorker( e.data.pdfBuffer, diff --git a/public/workers/alternate-merge.worker.d.ts b/public/workers/alternate-merge.worker.d.ts index d25e003..b6c97b6 100644 --- a/public/workers/alternate-merge.worker.d.ts +++ b/public/workers/alternate-merge.worker.d.ts @@ -1,23 +1,24 @@ declare const coherentpdf: typeof import('../../src/types/coherentpdf.global').coherentpdf; interface InterleaveFile { - name: string; - data: ArrayBuffer; + name: string; + data: ArrayBuffer; } interface InterleaveMessage { - command: 'interleave'; - files: InterleaveFile[]; + command: 'interleave'; + files: InterleaveFile[]; + cpdfUrl?: string; } interface InterleaveSuccessResponse { - status: 'success'; - pdfBytes: ArrayBuffer; + status: 'success'; + pdfBytes: ArrayBuffer; } interface InterleaveErrorResponse { - status: 'error'; - message: string; + status: 'error'; + message: string; } type InterleaveResponse = InterleaveSuccessResponse | InterleaveErrorResponse; diff --git a/public/workers/alternate-merge.worker.js b/public/workers/alternate-merge.worker.js index 5ed60e7..2e19b69 100644 --- a/public/workers/alternate-merge.worker.js +++ b/public/workers/alternate-merge.worker.js @@ -1,64 +1,109 @@ -const baseUrl = self.location.href.substring(0, self.location.href.lastIndexOf('/workers/') + 1); -self.importScripts(baseUrl + 'coherentpdf.browser.min.js'); +let cpdfLoaded = false; -self.onmessage = function (e) { - const { command, files } = e.data; +function loadCpdf(cpdfUrl) { + if (cpdfLoaded) return Promise.resolve(); - if (command === 'interleave') { - interleavePDFs(files); + return new Promise((resolve, reject) => { + if (typeof coherentpdf !== 'undefined') { + cpdfLoaded = true; + resolve(); + return; } + + try { + self.importScripts(cpdfUrl); + cpdfLoaded = true; + resolve(); + } catch (error) { + reject(new Error('Failed to load CoherentPDF: ' + error.message)); + } + }); +} + +self.onmessage = async function (e) { + const { command, files, cpdfUrl } = e.data; + + if (!cpdfUrl) { + self.postMessage({ + status: 'error', + message: + 'CoherentPDF URL not provided. Please configure it in WASM Settings.', + }); + return; + } + + try { + await loadCpdf(cpdfUrl); + } catch (error) { + self.postMessage({ + status: 'error', + message: error.message, + }); + return; + } + + if (command === 'interleave') { + interleavePDFs(files); + } }; function interleavePDFs(files) { - try { - const loadedPdfs = []; - const pageCounts = []; + try { + const loadedPdfs = []; + const pageCounts = []; - for (const file of files) { - const uint8Array = new Uint8Array(file.data); - const pdfDoc = coherentpdf.fromMemory(uint8Array, ""); - loadedPdfs.push(pdfDoc); - pageCounts.push(coherentpdf.pages(pdfDoc)); - } - - if (loadedPdfs.length < 2) { - throw new Error('At least two PDF files are required for interleaving.'); - } - - const maxPages = Math.max(...pageCounts); - - const pdfsToMerge = []; - const rangesToMerge = []; - - for (let i = 1; i <= maxPages; i++) { - for (let j = 0; j < loadedPdfs.length; j++) { - if (i <= pageCounts[j]) { - pdfsToMerge.push(loadedPdfs[j]); - rangesToMerge.push(coherentpdf.range(i, i)); - } - } - } - - if (pdfsToMerge.length === 0) { - throw new Error('No valid pages to merge.'); - } - - const mergedPdf = coherentpdf.mergeSame(pdfsToMerge, true, true, rangesToMerge); - - const mergedPdfBytes = coherentpdf.toMemory(mergedPdf, false, true); - const buffer = mergedPdfBytes.buffer; - coherentpdf.deletePdf(mergedPdf); - loadedPdfs.forEach(pdf => coherentpdf.deletePdf(pdf)); - - self.postMessage({ - status: 'success', - pdfBytes: buffer - }, [buffer]); - - } catch (error) { - self.postMessage({ - status: 'error', - message: error.message || 'Unknown error during interleave merge' - }); + for (const file of files) { + const uint8Array = new Uint8Array(file.data); + const pdfDoc = coherentpdf.fromMemory(uint8Array, ''); + loadedPdfs.push(pdfDoc); + pageCounts.push(coherentpdf.pages(pdfDoc)); } + + if (loadedPdfs.length < 2) { + throw new Error('At least two PDF files are required for interleaving.'); + } + + const maxPages = Math.max(...pageCounts); + + const pdfsToMerge = []; + const rangesToMerge = []; + + for (let i = 1; i <= maxPages; i++) { + for (let j = 0; j < loadedPdfs.length; j++) { + if (i <= pageCounts[j]) { + pdfsToMerge.push(loadedPdfs[j]); + rangesToMerge.push(coherentpdf.range(i, i)); + } + } + } + + if (pdfsToMerge.length === 0) { + throw new Error('No valid pages to merge.'); + } + + const mergedPdf = coherentpdf.mergeSame( + pdfsToMerge, + true, + true, + rangesToMerge + ); + + const mergedPdfBytes = coherentpdf.toMemory(mergedPdf, false, true); + const buffer = mergedPdfBytes.buffer; + coherentpdf.deletePdf(mergedPdf); + loadedPdfs.forEach((pdf) => coherentpdf.deletePdf(pdf)); + + self.postMessage( + { + status: 'success', + pdfBytes: buffer, + }, + [buffer] + ); + } catch (error) { + self.postMessage({ + status: 'error', + message: error.message || 'Unknown error during interleave merge', + }); + } } diff --git a/public/workers/edit-attachments.worker.d.ts b/public/workers/edit-attachments.worker.d.ts index fdca01a..0628209 100644 --- a/public/workers/edit-attachments.worker.d.ts +++ b/public/workers/edit-attachments.worker.d.ts @@ -4,6 +4,7 @@ interface GetAttachmentsMessage { command: 'get-attachments'; fileBuffer: ArrayBuffer; fileName: string; + cpdfUrl?: string; } interface EditAttachmentsMessage { @@ -11,13 +12,21 @@ interface EditAttachmentsMessage { fileBuffer: ArrayBuffer; fileName: string; attachmentsToRemove: number[]; + cpdfUrl?: string; } -type EditAttachmentsWorkerMessage = GetAttachmentsMessage | EditAttachmentsMessage; +type EditAttachmentsWorkerMessage = + | GetAttachmentsMessage + | EditAttachmentsMessage; interface GetAttachmentsSuccessResponse { status: 'success'; - attachments: Array<{ index: number; name: string; page: number; data: ArrayBuffer }>; + attachments: Array<{ + index: number; + name: string; + page: number; + data: ArrayBuffer; + }>; fileName: string; } @@ -37,6 +46,12 @@ interface EditAttachmentsErrorResponse { message: string; } -type GetAttachmentsResponse = GetAttachmentsSuccessResponse | GetAttachmentsErrorResponse; -type EditAttachmentsResponse = EditAttachmentsSuccessResponse | EditAttachmentsErrorResponse; -type EditAttachmentsWorkerResponse = GetAttachmentsResponse | EditAttachmentsResponse; \ No newline at end of file +type GetAttachmentsResponse = + | GetAttachmentsSuccessResponse + | GetAttachmentsErrorResponse; +type EditAttachmentsResponse = + | EditAttachmentsSuccessResponse + | EditAttachmentsErrorResponse; +type EditAttachmentsWorkerResponse = + | GetAttachmentsResponse + | EditAttachmentsResponse; diff --git a/public/workers/edit-attachments.worker.js b/public/workers/edit-attachments.worker.js index b935932..76be114 100644 --- a/public/workers/edit-attachments.worker.js +++ b/public/workers/edit-attachments.worker.js @@ -1,5 +1,24 @@ -const baseUrl = self.location.href.substring(0, self.location.href.lastIndexOf('/workers/') + 1); -self.importScripts(baseUrl + 'coherentpdf.browser.min.js'); +let cpdfLoaded = false; + +function loadCpdf(cpdfUrl) { + if (cpdfLoaded) return Promise.resolve(); + + return new Promise((resolve, reject) => { + if (typeof coherentpdf !== 'undefined') { + cpdfLoaded = true; + resolve(); + return; + } + + try { + self.importScripts(cpdfUrl); + cpdfLoaded = true; + resolve(); + } catch (error) { + reject(new Error('Failed to load CoherentPDF: ' + error.message)); + } + }); +} function getAttachmentsFromPDFInWorker(fileBuffer, fileName) { try { @@ -11,7 +30,7 @@ function getAttachmentsFromPDFInWorker(fileBuffer, fileName) { } catch (error) { self.postMessage({ status: 'error', - message: `Failed to load PDF: ${fileName}. Error: ${error.message || error}` + message: `Failed to load PDF: ${fileName}. Error: ${error.message || error}`, }); return; } @@ -23,7 +42,7 @@ function getAttachmentsFromPDFInWorker(fileBuffer, fileName) { self.postMessage({ status: 'success', attachments: [], - fileName: fileName + fileName: fileName, }); coherentpdf.deletePdf(pdf); return; @@ -37,13 +56,16 @@ function getAttachmentsFromPDFInWorker(fileBuffer, fileName) { const attachmentData = coherentpdf.getAttachmentData(i); const dataArray = new Uint8Array(attachmentData); - const buffer = dataArray.buffer.slice(dataArray.byteOffset, dataArray.byteOffset + dataArray.byteLength); + const buffer = dataArray.buffer.slice( + dataArray.byteOffset, + dataArray.byteOffset + dataArray.byteLength + ); attachments.push({ index: i, name: String(name), page: Number(page), - data: buffer + data: buffer, }); } catch (error) { console.warn(`Failed to get attachment ${i} from ${fileName}:`, error); @@ -56,22 +78,27 @@ function getAttachmentsFromPDFInWorker(fileBuffer, fileName) { const response = { status: 'success', attachments: attachments, - fileName: fileName + fileName: fileName, }; - const transferBuffers = attachments.map(att => att.data); + const transferBuffers = attachments.map((att) => att.data); self.postMessage(response, transferBuffers); } catch (error) { self.postMessage({ status: 'error', - message: error instanceof Error - ? error.message - : 'Unknown error occurred during attachment listing.' + message: + error instanceof Error + ? error.message + : 'Unknown error occurred during attachment listing.', }); } } -function editAttachmentsInPDFInWorker(fileBuffer, fileName, attachmentsToRemove) { +function editAttachmentsInPDFInWorker( + fileBuffer, + fileName, + attachmentsToRemove +) { try { const uint8Array = new Uint8Array(fileBuffer); @@ -81,7 +108,7 @@ function editAttachmentsInPDFInWorker(fileBuffer, fileName, attachmentsToRemove) } catch (error) { self.postMessage({ status: 'error', - message: `Failed to load PDF: ${fileName}. Error: ${error.message || error}` + message: `Failed to load PDF: ${fileName}. Error: ${error.message || error}`, }); return; } @@ -103,7 +130,7 @@ function editAttachmentsInPDFInWorker(fileBuffer, fileName, attachmentsToRemove) attachmentsToKeep.push({ name: String(name), page: Number(page), - data: dataCopy + data: dataCopy, }); } } @@ -114,9 +141,18 @@ function editAttachmentsInPDFInWorker(fileBuffer, fileName, attachmentsToRemove) for (const attachment of attachmentsToKeep) { if (attachment.page === 0) { - coherentpdf.attachFileFromMemory(attachment.data, attachment.name, pdf); + coherentpdf.attachFileFromMemory( + attachment.data, + attachment.name, + pdf + ); } else { - coherentpdf.attachFileToPageFromMemory(attachment.data, attachment.name, pdf, attachment.page); + coherentpdf.attachFileToPageFromMemory( + attachment.data, + attachment.name, + pdf, + attachment.page + ); } } } @@ -124,29 +160,58 @@ function editAttachmentsInPDFInWorker(fileBuffer, fileName, attachmentsToRemove) const modifiedBytes = coherentpdf.toMemory(pdf, false, true); coherentpdf.deletePdf(pdf); - const buffer = modifiedBytes.buffer.slice(modifiedBytes.byteOffset, modifiedBytes.byteOffset + modifiedBytes.byteLength); + const buffer = modifiedBytes.buffer.slice( + modifiedBytes.byteOffset, + modifiedBytes.byteOffset + modifiedBytes.byteLength + ); const response = { status: 'success', modifiedPDF: buffer, - fileName: fileName + fileName: fileName, }; self.postMessage(response, [response.modifiedPDF]); } catch (error) { self.postMessage({ status: 'error', - message: error instanceof Error - ? error.message - : 'Unknown error occurred during attachment editing.' + message: + error instanceof Error + ? error.message + : 'Unknown error occurred during attachment editing.', }); } } -self.onmessage = (e) => { +self.onmessage = async function (e) { + const { cpdfUrl } = e.data; + + if (!cpdfUrl) { + self.postMessage({ + status: 'error', + message: + 'CoherentPDF URL not provided. Please configure it in WASM Settings.', + }); + return; + } + + try { + await loadCpdf(cpdfUrl); + } catch (error) { + self.postMessage({ + status: 'error', + message: error.message, + }); + return; + } + if (e.data.command === 'get-attachments') { getAttachmentsFromPDFInWorker(e.data.fileBuffer, e.data.fileName); } else if (e.data.command === 'edit-attachments') { - editAttachmentsInPDFInWorker(e.data.fileBuffer, e.data.fileName, e.data.attachmentsToRemove); + editAttachmentsInPDFInWorker( + e.data.fileBuffer, + e.data.fileName, + e.data.attachmentsToRemove + ); } -}; \ No newline at end of file +}; diff --git a/public/workers/extract-attachments.worker.d.ts b/public/workers/extract-attachments.worker.d.ts index 2bf1a2d..a117630 100644 --- a/public/workers/extract-attachments.worker.d.ts +++ b/public/workers/extract-attachments.worker.d.ts @@ -4,6 +4,7 @@ interface ExtractAttachmentsMessage { command: 'extract-attachments'; fileBuffers: ArrayBuffer[]; fileNames: string[]; + cpdfUrl?: string; } interface ExtractAttachmentSuccessResponse { @@ -16,4 +17,6 @@ interface ExtractAttachmentErrorResponse { message: string; } -type ExtractAttachmentResponse = ExtractAttachmentSuccessResponse | ExtractAttachmentErrorResponse; \ No newline at end of file +type ExtractAttachmentResponse = + | ExtractAttachmentSuccessResponse + | ExtractAttachmentErrorResponse; diff --git a/public/workers/extract-attachments.worker.js b/public/workers/extract-attachments.worker.js index 1572d00..ca7e085 100644 --- a/public/workers/extract-attachments.worker.js +++ b/public/workers/extract-attachments.worker.js @@ -1,5 +1,24 @@ -const baseUrl = self.location.href.substring(0, self.location.href.lastIndexOf('/workers/') + 1); -self.importScripts(baseUrl + 'coherentpdf.browser.min.js'); +let cpdfLoaded = false; + +function loadCpdf(cpdfUrl) { + if (cpdfLoaded) return Promise.resolve(); + + return new Promise((resolve, reject) => { + if (typeof coherentpdf !== 'undefined') { + cpdfLoaded = true; + resolve(); + return; + } + + try { + self.importScripts(cpdfUrl); + cpdfLoaded = true; + resolve(); + } catch (error) { + reject(new Error('Failed to load CoherentPDF: ' + error.message)); + } + }); +} function extractAttachmentsFromPDFsInWorker(fileBuffers, fileNames) { try { @@ -37,7 +56,7 @@ function extractAttachmentsFromPDFsInWorker(fileBuffers, fileNames) { let uniqueName = attachmentName; let counter = 1; - while (allAttachments.some(att => att.name === uniqueName)) { + while (allAttachments.some((att) => att.name === uniqueName)) { const nameParts = attachmentName.split('.'); if (nameParts.length > 1) { const extension = nameParts.pop(); @@ -56,10 +75,13 @@ function extractAttachmentsFromPDFsInWorker(fileBuffers, fileNames) { allAttachments.push({ name: uniqueName, - data: attachmentData.buffer.slice(0) + data: attachmentData.buffer.slice(0), }); } catch (error) { - console.warn(`Failed to extract attachment ${j} from ${fileName}:`, error); + console.warn( + `Failed to extract attachment ${j} from ${fileName}:`, + error + ); } } @@ -70,21 +92,21 @@ function extractAttachmentsFromPDFsInWorker(fileBuffers, fileNames) { if (allAttachments.length === 0) { self.postMessage({ status: 'error', - message: 'No attachments were found in the selected PDF(s).' + message: 'No attachments were found in the selected PDF(s).', }); return; } const response = { status: 'success', - attachments: [] + attachments: [], }; const transferBuffers = []; for (const attachment of allAttachments) { response.attachments.push({ name: attachment.name, - data: attachment.data + data: attachment.data, }); transferBuffers.push(attachment.data); } @@ -93,15 +115,37 @@ function extractAttachmentsFromPDFsInWorker(fileBuffers, fileNames) { } catch (error) { self.postMessage({ status: 'error', - message: error instanceof Error - ? error.message - : 'Unknown error occurred during attachment extraction.' + message: + error instanceof Error + ? error.message + : 'Unknown error occurred during attachment extraction.', }); } } -self.onmessage = (e) => { +self.onmessage = async function (e) { + const { cpdfUrl } = e.data; + + if (!cpdfUrl) { + self.postMessage({ + status: 'error', + message: + 'CoherentPDF URL not provided. Please configure it in WASM Settings.', + }); + return; + } + + try { + await loadCpdf(cpdfUrl); + } catch (error) { + self.postMessage({ + status: 'error', + message: error.message, + }); + return; + } + if (e.data.command === 'extract-attachments') { extractAttachmentsFromPDFsInWorker(e.data.fileBuffers, e.data.fileNames); } -}; \ No newline at end of file +}; diff --git a/public/workers/json-to-pdf.worker.d.ts b/public/workers/json-to-pdf.worker.d.ts index 2da9dc4..9f824b4 100644 --- a/public/workers/json-to-pdf.worker.d.ts +++ b/public/workers/json-to-pdf.worker.d.ts @@ -4,6 +4,7 @@ interface ConvertJSONToPDFMessage { command: 'convert'; fileBuffers: ArrayBuffer[]; fileNames: string[]; + cpdfUrl?: string; } interface JSONToPDFSuccessResponse { @@ -17,4 +18,3 @@ interface JSONToPDFErrorResponse { } type JSONToPDFResponse = JSONToPDFSuccessResponse | JSONToPDFErrorResponse; - diff --git a/public/workers/json-to-pdf.worker.js b/public/workers/json-to-pdf.worker.js index 18d405c..e590c97 100644 --- a/public/workers/json-to-pdf.worker.js +++ b/public/workers/json-to-pdf.worker.js @@ -1,5 +1,24 @@ -const baseUrl = self.location.href.substring(0, self.location.href.lastIndexOf('/workers/') + 1); -self.importScripts(baseUrl + 'coherentpdf.browser.min.js'); +let cpdfLoaded = false; + +function loadCpdf(cpdfUrl) { + if (cpdfLoaded) return Promise.resolve(); + + return new Promise((resolve, reject) => { + if (typeof coherentpdf !== 'undefined') { + cpdfLoaded = true; + resolve(); + return; + } + + try { + self.importScripts(cpdfUrl); + cpdfLoaded = true; + resolve(); + } catch (error) { + reject(new Error('Failed to load CoherentPDF: ' + error.message)); + } + }); +} function convertJSONsToPDFInWorker(fileBuffers, fileNames) { try { @@ -15,13 +34,12 @@ function convertJSONsToPDFInWorker(fileBuffers, fileNames) { try { pdf = coherentpdf.fromJSONMemory(uint8Array); } catch (error) { - const errorMsg = error && error.message - ? error.message - : 'Unknown error'; + const errorMsg = + error && error.message ? error.message : 'Unknown error'; throw new Error( `Failed to convert "${fileName}" to PDF. ` + - `The JSON file must be in the format produced by cpdf's outputJSONMemory. ` + - `Error: ${errorMsg}` + `The JSON file must be in the format produced by cpdf's outputJSONMemory. ` + + `Error: ${errorMsg}` ); } @@ -56,9 +74,29 @@ function convertJSONsToPDFInWorker(fileBuffers, fileNames) { } } -self.onmessage = (e) => { +self.onmessage = async function (e) { + const { cpdfUrl } = e.data; + + if (!cpdfUrl) { + self.postMessage({ + status: 'error', + message: + 'CoherentPDF URL not provided. Please configure it in WASM Settings.', + }); + return; + } + + try { + await loadCpdf(cpdfUrl); + } catch (error) { + self.postMessage({ + status: 'error', + message: error.message, + }); + return; + } + if (e.data.command === 'convert') { convertJSONsToPDFInWorker(e.data.fileBuffers, e.data.fileNames); } }; - diff --git a/public/workers/merge.worker.d.ts b/public/workers/merge.worker.d.ts index 8e20242..168eacb 100644 --- a/public/workers/merge.worker.d.ts +++ b/public/workers/merge.worker.d.ts @@ -1,33 +1,34 @@ declare const coherentpdf: typeof import('../../src/types/coherentpdf.global').coherentpdf; interface MergeJob { - fileName: string; - rangeType: 'all' | 'specific' | 'single' | 'range'; - rangeString?: string; - pageIndex?: number; - startPage?: number; - endPage?: number; + fileName: string; + rangeType: 'all' | 'specific' | 'single' | 'range'; + rangeString?: string; + pageIndex?: number; + startPage?: number; + endPage?: number; } interface MergeFile { - name: string; - data: ArrayBuffer; + name: string; + data: ArrayBuffer; } interface MergeMessage { - command: 'merge'; - files: MergeFile[]; - jobs: MergeJob[]; + command: 'merge'; + files: MergeFile[]; + jobs: MergeJob[]; + cpdfUrl?: string; } interface MergeSuccessResponse { - status: 'success'; - pdfBytes: ArrayBuffer; + status: 'success'; + pdfBytes: ArrayBuffer; } interface MergeErrorResponse { - status: 'error'; - message: string; + status: 'error'; + message: string; } type MergeResponse = MergeSuccessResponse | MergeErrorResponse; diff --git a/public/workers/merge.worker.js b/public/workers/merge.worker.js index 23572cc..efca0fa 100644 --- a/public/workers/merge.worker.js +++ b/public/workers/merge.worker.js @@ -1,71 +1,116 @@ -const baseUrl = self.location.href.substring(0, self.location.href.lastIndexOf('/workers/') + 1); -self.importScripts(baseUrl + 'coherentpdf.browser.min.js'); +let cpdfLoaded = false; -self.onmessage = function (e) { - const { command, files, jobs } = e.data; +function loadCpdf(cpdfUrl) { + if (cpdfLoaded) return Promise.resolve(); - if (command === 'merge') { - mergePDFs(files, jobs); + return new Promise((resolve, reject) => { + if (typeof coherentpdf !== 'undefined') { + cpdfLoaded = true; + resolve(); + return; } + + try { + self.importScripts(cpdfUrl); + cpdfLoaded = true; + resolve(); + } catch (error) { + reject(new Error('Failed to load CoherentPDF: ' + error.message)); + } + }); +} + +self.onmessage = async function (e) { + const { command, files, jobs, cpdfUrl } = e.data; + + if (!cpdfUrl) { + self.postMessage({ + status: 'error', + message: + 'CoherentPDF URL not provided. Please configure it in WASM Settings.', + }); + return; + } + + try { + await loadCpdf(cpdfUrl); + } catch (error) { + self.postMessage({ + status: 'error', + message: error.message, + }); + return; + } + + if (command === 'merge') { + mergePDFs(files, jobs); + } }; function mergePDFs(files, jobs) { - try { - const loadedPdfs = {}; - const pdfsToMerge = []; - const rangesToMerge = []; + try { + const loadedPdfs = {}; + const pdfsToMerge = []; + const rangesToMerge = []; - for (const file of files) { - const uint8Array = new Uint8Array(file.data); - const pdfDoc = coherentpdf.fromMemory(uint8Array, ""); - loadedPdfs[file.name] = pdfDoc; - } - - for (const job of jobs) { - const sourcePdf = loadedPdfs[job.fileName]; - if (!sourcePdf) continue; - - let range; - if (job.rangeType === 'all') { - range = coherentpdf.all(sourcePdf); - } else if (job.rangeType === 'specific') { - if (coherentpdf.validatePagespec(job.rangeString)) { - range = coherentpdf.parsePagespec(sourcePdf, job.rangeString); - } else { - range = coherentpdf.all(sourcePdf); - } - } else if (job.rangeType === 'single') { - const pageNum = job.pageIndex + 1; - range = coherentpdf.range(pageNum, pageNum); - } else if (job.rangeType === 'range') { - range = coherentpdf.range(job.startPage, job.endPage); - } - - pdfsToMerge.push(sourcePdf); - rangesToMerge.push(range); - } - - if (pdfsToMerge.length === 0) { - throw new Error('No valid files or pages to merge.'); - } - - const mergedPdf = coherentpdf.mergeSame(pdfsToMerge, true, true, rangesToMerge); - - const mergedPdfBytes = coherentpdf.toMemory(mergedPdf, false, true); - const buffer = mergedPdfBytes.buffer; - - coherentpdf.deletePdf(mergedPdf); - Object.values(loadedPdfs).forEach(pdf => coherentpdf.deletePdf(pdf)); - - self.postMessage({ - status: 'success', - pdfBytes: buffer - }, [buffer]); - - } catch (error) { - self.postMessage({ - status: 'error', - message: error.message || 'Unknown error during merge' - }); + for (const file of files) { + const uint8Array = new Uint8Array(file.data); + const pdfDoc = coherentpdf.fromMemory(uint8Array, ''); + loadedPdfs[file.name] = pdfDoc; } + + for (const job of jobs) { + const sourcePdf = loadedPdfs[job.fileName]; + if (!sourcePdf) continue; + + let range; + if (job.rangeType === 'all') { + range = coherentpdf.all(sourcePdf); + } else if (job.rangeType === 'specific') { + if (coherentpdf.validatePagespec(job.rangeString)) { + range = coherentpdf.parsePagespec(sourcePdf, job.rangeString); + } else { + range = coherentpdf.all(sourcePdf); + } + } else if (job.rangeType === 'single') { + const pageNum = job.pageIndex + 1; + range = coherentpdf.range(pageNum, pageNum); + } else if (job.rangeType === 'range') { + range = coherentpdf.range(job.startPage, job.endPage); + } + + pdfsToMerge.push(sourcePdf); + rangesToMerge.push(range); + } + + if (pdfsToMerge.length === 0) { + throw new Error('No valid files or pages to merge.'); + } + + const mergedPdf = coherentpdf.mergeSame( + pdfsToMerge, + true, + true, + rangesToMerge + ); + + const mergedPdfBytes = coherentpdf.toMemory(mergedPdf, false, true); + const buffer = mergedPdfBytes.buffer; + + coherentpdf.deletePdf(mergedPdf); + Object.values(loadedPdfs).forEach((pdf) => coherentpdf.deletePdf(pdf)); + + self.postMessage( + { + status: 'success', + pdfBytes: buffer, + }, + [buffer] + ); + } catch (error) { + self.postMessage({ + status: 'error', + message: error.message || 'Unknown error during merge', + }); + } } diff --git a/public/workers/pdf-to-json.worker.d.ts b/public/workers/pdf-to-json.worker.d.ts index fe50026..d72dd44 100644 --- a/public/workers/pdf-to-json.worker.d.ts +++ b/public/workers/pdf-to-json.worker.d.ts @@ -4,6 +4,7 @@ interface ConvertPDFToJSONMessage { command: 'convert'; fileBuffers: ArrayBuffer[]; fileNames: string[]; + cpdfUrl?: string; } interface PDFToJSONSuccessResponse { @@ -17,4 +18,3 @@ interface PDFToJSONErrorResponse { } type PDFToJSONResponse = PDFToJSONSuccessResponse | PDFToJSONErrorResponse; - diff --git a/public/workers/pdf-to-json.worker.js b/public/workers/pdf-to-json.worker.js index 03f67f7..4eb87fd 100644 --- a/public/workers/pdf-to-json.worker.js +++ b/public/workers/pdf-to-json.worker.js @@ -1,5 +1,24 @@ -const baseUrl = self.location.href.substring(0, self.location.href.lastIndexOf('/workers/') + 1); -self.importScripts(baseUrl + 'coherentpdf.browser.min.js'); +let cpdfLoaded = false; + +function loadCpdf(cpdfUrl) { + if (cpdfLoaded) return Promise.resolve(); + + return new Promise((resolve, reject) => { + if (typeof coherentpdf !== 'undefined') { + cpdfLoaded = true; + resolve(); + return; + } + + try { + self.importScripts(cpdfUrl); + cpdfLoaded = true; + resolve(); + } catch (error) { + reject(new Error('Failed to load CoherentPDF: ' + error.message)); + } + }); +} function convertPDFsToJSONInWorker(fileBuffers, fileNames) { try { @@ -12,8 +31,6 @@ function convertPDFsToJSONInWorker(fileBuffers, fileNames) { const uint8Array = new Uint8Array(buffer); const pdf = coherentpdf.fromMemory(uint8Array, ''); - //TODO:@ALAM -> add options for users to select these settings - // parse_content: true, no_stream_data: false, decompress_streams: false const jsonData = coherentpdf.outputJSONMemory(true, false, false, pdf); const jsonBuffer = jsonData.buffer.slice(0); @@ -44,9 +61,29 @@ function convertPDFsToJSONInWorker(fileBuffers, fileNames) { } } -self.onmessage = (e) => { +self.onmessage = async function (e) { + const { cpdfUrl } = e.data; + + if (!cpdfUrl) { + self.postMessage({ + status: 'error', + message: + 'CoherentPDF URL not provided. Please configure it in WASM Settings.', + }); + return; + } + + try { + await loadCpdf(cpdfUrl); + } catch (error) { + self.postMessage({ + status: 'error', + message: error.message, + }); + return; + } + if (e.data.command === 'convert') { convertPDFsToJSONInWorker(e.data.fileBuffers, e.data.fileNames); } }; - diff --git a/public/workers/table-of-contents.worker.d.ts b/public/workers/table-of-contents.worker.d.ts index 7f53354..11c914d 100644 --- a/public/workers/table-of-contents.worker.d.ts +++ b/public/workers/table-of-contents.worker.d.ts @@ -7,6 +7,7 @@ interface GenerateTOCMessage { fontSize: number; fontFamily: number; addBookmark: boolean; + cpdfUrl?: string; } interface TOCSuccessResponse { diff --git a/public/workers/table-of-contents.worker.js b/public/workers/table-of-contents.worker.js index 088debb..43f82dc 100644 --- a/public/workers/table-of-contents.worker.js +++ b/public/workers/table-of-contents.worker.js @@ -1,5 +1,24 @@ -const baseUrl = self.location.href.substring(0, self.location.href.lastIndexOf('/workers/') + 1); -self.importScripts(baseUrl + 'coherentpdf.browser.min.js'); +let cpdfLoaded = false; + +function loadCpdf(cpdfUrl) { + if (cpdfLoaded) return Promise.resolve(); + + return new Promise((resolve, reject) => { + if (typeof coherentpdf !== 'undefined') { + cpdfLoaded = true; + resolve(); + return; + } + + try { + self.importScripts(cpdfUrl); + cpdfLoaded = true; + resolve(); + } catch (error) { + reject(new Error('Failed to load CoherentPDF: ' + error.message)); + } + }); +} function generateTableOfContentsInWorker( pdfData, @@ -49,7 +68,28 @@ function generateTableOfContentsInWorker( } } -self.onmessage = (e) => { +self.onmessage = async function (e) { + const { cpdfUrl } = e.data; + + if (!cpdfUrl) { + self.postMessage({ + status: 'error', + message: + 'CoherentPDF URL not provided. Please configure it in WASM Settings.', + }); + return; + } + + try { + await loadCpdf(cpdfUrl); + } catch (error) { + self.postMessage({ + status: 'error', + message: error.message, + }); + return; + } + if (e.data.command === 'generate-toc') { generateTableOfContentsInWorker( e.data.pdfData, diff --git a/scripts/generate-i18n-pages.mjs b/scripts/generate-i18n-pages.mjs index b4bbc9f..5a32db8 100644 --- a/scripts/generate-i18n-pages.mjs +++ b/scripts/generate-i18n-pages.mjs @@ -24,6 +24,24 @@ const KEY_MAPPING = { 404: 'notFound', }; +function loadAllTranslations() { + const translations = {}; + for (const lang of languages) { + if (lang === 'en') continue; + const commonPath = path.join(LOCALES_DIR, `${lang}/common.json`); + const toolsPath = path.join(LOCALES_DIR, `${lang}/tools.json`); + translations[lang] = { + common: fs.existsSync(commonPath) + ? JSON.parse(fs.readFileSync(commonPath, 'utf-8')) + : {}, + tools: fs.existsSync(toolsPath) + ? JSON.parse(fs.readFileSync(toolsPath, 'utf-8')) + : {}, + }; + } + return translations; +} + // TODO@ALAM: Let users build only a single language function buildUrl(langPrefix, pagePath) { const parts = [SITE_URL]; @@ -33,182 +51,228 @@ function buildUrl(langPrefix, pagePath) { return parts.filter(Boolean).join('/').replace(/\/+$/, '') || SITE_URL; } +function processFileForLanguage( + originalContent, + file, + lang, + translations, + langDir +) { + const filenameNoExt = file.replace('.html', ''); + let translationKey = toCamelCase(filenameNoExt); + if (KEY_MAPPING[filenameNoExt]) { + translationKey = KEY_MAPPING[filenameNoExt]; + } + + const { tools } = translations[lang]; + const dom = new JSDOM(originalContent); + const document = dom.window.document; + + document.documentElement.lang = lang; + document.documentElement.dir = lang === 'ar' ? 'rtl' : 'ltr'; + + let title = null; + let description = null; + + if (tools[translationKey]) { + title = + tools[translationKey].pageTitle || + (tools[translationKey].name + ? `${tools[translationKey].name} - BentoPDF` + : null); + description = tools[translationKey].subtitle; + } + + if (title) { + document.title = title; + const metaTitle = document.querySelector('meta[property="og:title"]'); + if (metaTitle) metaTitle.content = title; + const metaTwitterTitle = document.querySelector( + 'meta[name="twitter:title"]' + ); + if (metaTwitterTitle) metaTwitterTitle.content = title; + } + + if (description) { + const metaDesc = document.querySelector('meta[name="description"]'); + if (metaDesc) metaDesc.content = description; + const metaOgDesc = document.querySelector( + 'meta[property="og:description"]' + ); + if (metaOgDesc) metaOgDesc.content = description; + const metaTwitterDesc = document.querySelector( + 'meta[name="twitter:description"]' + ); + if (metaTwitterDesc) metaTwitterDesc.content = description; + } + + document + .querySelectorAll('link[rel="alternate"][hreflang]') + .forEach((el) => el.remove()); + + const pagePath = filenameNoExt === 'index' ? '' : filenameNoExt; + + languages.forEach((l) => { + const link = document.createElement('link'); + link.rel = 'alternate'; + link.hreflang = l; + link.href = buildUrl(l === 'en' ? '' : l, pagePath); + document.head.appendChild(link); + }); + + const defaultLink = document.createElement('link'); + defaultLink.rel = 'alternate'; + defaultLink.hreflang = 'x-default'; + defaultLink.href = buildUrl('', pagePath); + document.head.appendChild(defaultLink); + + let canonical = document.querySelector('link[rel="canonical"]'); + if (!canonical) { + canonical = document.createElement('link'); + canonical.rel = 'canonical'; + document.head.appendChild(canonical); + } + canonical.href = buildUrl(lang, pagePath); + + const links = document.querySelectorAll('a[href]'); + links.forEach((link) => { + const href = link.getAttribute('href'); + if (!href) return; + + if ( + href.startsWith('http') || + href.startsWith('//') || + href.startsWith('#') || + href.startsWith('mailto:') || + href.startsWith('tel:') || + href.startsWith('javascript:') + ) { + return; + } + + if (href.startsWith('/assets/') || href.includes('/assets/')) return; + + const langPrefixRegex = new RegExp( + `^(${BASE_PATH})?/(${languages.join('|')})(/|$)` + ); + if (langPrefixRegex.test(href)) return; + + let newHref; + if (href.startsWith('/')) { + const pathWithoutBase = href.startsWith(BASE_PATH) + ? href.slice(BASE_PATH.length) + : href; + newHref = `${BASE_PATH}/${lang}${pathWithoutBase}`; + } else { + newHref = `${BASE_PATH}/${lang}/${href}`; + } + + link.setAttribute('href', newHref); + }); + + const result = dom.serialize(); + + dom.window.close(); + + fs.writeFileSync(path.join(langDir, file), result); +} + +function updateEnglishFile(filePath, originalContent) { + const filenameNoExt = path.basename(filePath, '.html'); + const dom = new JSDOM(originalContent); + const document = dom.window.document; + + document + .querySelectorAll('link[rel="alternate"][hreflang]') + .forEach((el) => el.remove()); + + const pagePath = filenameNoExt === 'index' ? '' : filenameNoExt; + + languages.forEach((l) => { + const link = document.createElement('link'); + link.rel = 'alternate'; + link.hreflang = l; + link.href = buildUrl(l === 'en' ? '' : l, pagePath); + document.head.appendChild(link); + }); + + const defaultLink = document.createElement('link'); + defaultLink.rel = 'alternate'; + defaultLink.hreflang = 'x-default'; + defaultLink.href = buildUrl('', pagePath); + document.head.appendChild(defaultLink); + + const result = dom.serialize(); + + dom.window.close(); + + fs.writeFileSync(filePath, result); +} + async function generateI18nPages() { console.log('🌍 Generating i18n pages...'); console.log(` SITE_URL: ${SITE_URL}`); console.log(` BASE_PATH: ${BASE_PATH || '/'}`); + console.log(` Languages: ${languages.length} (${languages.join(', ')})`); if (!fs.existsSync(DIST_DIR)) { console.error('❌ dist directory not found. Please run build first.'); process.exit(1); } + console.log(' Loading translations...'); + const translations = loadAllTranslations(); + const htmlFiles = fs .readdirSync(DIST_DIR) .filter((file) => file.endsWith('.html')); + console.log(` Processing ${htmlFiles.length} HTML files...`); + + for (const lang of languages) { + if (lang === 'en') continue; + const langDir = path.join(DIST_DIR, lang); + if (!fs.existsSync(langDir)) { + fs.mkdirSync(langDir, { recursive: true }); + } + } + + let processed = 0; + const total = htmlFiles.length * (languages.length - 1); + for (const file of htmlFiles) { const filePath = path.join(DIST_DIR, file); const originalContent = fs.readFileSync(filePath, 'utf-8'); - const filenameNoExt = file.replace('.html', ''); - - let translationKey = toCamelCase(filenameNoExt); - if (KEY_MAPPING[filenameNoExt]) { - translationKey = KEY_MAPPING[filenameNoExt]; - } for (const lang of languages) { if (lang === 'en') continue; const langDir = path.join(DIST_DIR, lang); - if (!fs.existsSync(langDir)) { - fs.mkdirSync(langDir, { recursive: true }); + + processFileForLanguage( + originalContent, + file, + lang, + translations, + langDir + ); + + processed++; + if (processed % 10 === 0 || processed === total) { + console.log(` Progress: ${processed}/${total} pages`); } - const commonPath = path.join(LOCALES_DIR, `${lang}/common.json`); - const toolsPath = path.join(LOCALES_DIR, `${lang}/tools.json`); - - const common = fs.existsSync(commonPath) - ? JSON.parse(fs.readFileSync(commonPath, 'utf-8')) - : {}; - const tools = fs.existsSync(toolsPath) - ? JSON.parse(fs.readFileSync(toolsPath, 'utf-8')) - : {}; - - const dom = new JSDOM(originalContent); - const document = dom.window.document; - - document.documentElement.lang = lang; - - let title = null; - let description = null; - - if (tools[translationKey]) { - title = - tools[translationKey].pageTitle || - (tools[translationKey].name - ? `${tools[translationKey].name} - BentoPDF` - : null); - description = tools[translationKey].subtitle; - } - - if (title) { - document.title = title; - const metaTitle = document.querySelector('meta[property="og:title"]'); - if (metaTitle) metaTitle.content = title; - const metaTwitterTitle = document.querySelector( - 'meta[name="twitter:title"]' - ); - if (metaTwitterTitle) metaTwitterTitle.content = title; - } - - if (description) { - const metaDesc = document.querySelector('meta[name="description"]'); - if (metaDesc) metaDesc.content = description; - const metaOgDesc = document.querySelector( - 'meta[property="og:description"]' - ); - if (metaOgDesc) metaOgDesc.content = description; - const metaTwitterDesc = document.querySelector( - 'meta[name="twitter:description"]' - ); - if (metaTwitterDesc) metaTwitterDesc.content = description; - } - - document - .querySelectorAll('link[rel="alternate"][hreflang]') - .forEach((el) => el.remove()); - - const pagePath = filenameNoExt === 'index' ? '' : filenameNoExt; - - languages.forEach((l) => { - const link = document.createElement('link'); - link.rel = 'alternate'; - link.hreflang = l; - link.href = buildUrl(l === 'en' ? '' : l, pagePath); - document.head.appendChild(link); - }); - - const defaultLink = document.createElement('link'); - defaultLink.rel = 'alternate'; - defaultLink.hreflang = 'x-default'; - defaultLink.href = buildUrl('', pagePath); - document.head.appendChild(defaultLink); - - let canonical = document.querySelector('link[rel="canonical"]'); - if (!canonical) { - canonical = document.createElement('link'); - canonical.rel = 'canonical'; - document.head.appendChild(canonical); - } - canonical.href = buildUrl(lang, pagePath); - - const links = document.querySelectorAll('a[href]'); - links.forEach((link) => { - const href = link.getAttribute('href'); - if (!href) return; - - if ( - href.startsWith('http') || - href.startsWith('//') || - href.startsWith('#') || - href.startsWith('mailto:') || - href.startsWith('tel:') || - href.startsWith('javascript:') - ) { - return; - } - - if (href.startsWith('/assets/') || href.includes('/assets/')) return; - - const langPrefixRegex = new RegExp( - `^(${BASE_PATH})?/(${languages.join('|')})(/|$)` - ); - if (langPrefixRegex.test(href)) return; - - let newHref; - if (href.startsWith('/')) { - const pathWithoutBase = href.startsWith(BASE_PATH) - ? href.slice(BASE_PATH.length) - : href; - newHref = `${BASE_PATH}/${lang}${pathWithoutBase}`; - } else { - newHref = `${lang}/${href}`; - } - - link.setAttribute('href', newHref); - }); - - fs.writeFileSync(path.join(langDir, file), dom.serialize()); + // Clean up JSDOM instances + await new Promise((resolve) => setImmediate(resolve)); } - const dom = new JSDOM(originalContent); - const document = dom.window.document; - - document - .querySelectorAll('link[rel="alternate"][hreflang]') - .forEach((el) => el.remove()); - - const pagePath = filenameNoExt === 'index' ? '' : filenameNoExt; - - languages.forEach((l) => { - const link = document.createElement('link'); - link.rel = 'alternate'; - link.hreflang = l; - link.href = buildUrl(l === 'en' ? '' : l, pagePath); - document.head.appendChild(link); - }); - - const defaultLink = document.createElement('link'); - defaultLink.rel = 'alternate'; - defaultLink.hreflang = 'x-default'; - defaultLink.href = buildUrl('', pagePath); - document.head.appendChild(defaultLink); - - fs.writeFileSync(filePath, dom.serialize()); + updateEnglishFile(filePath, originalContent); } console.log('✅ i18n pages generated successfully!'); } -generateI18nPages().catch(console.error); +generateI18nPages().catch((err) => { + console.error('❌ i18n page generation failed:', err); + process.exit(1); +}); diff --git a/scripts/prepare-airgap.sh b/scripts/prepare-airgap.sh new file mode 100755 index 0000000..2bae28e --- /dev/null +++ b/scripts/prepare-airgap.sh @@ -0,0 +1,1083 @@ +#!/usr/bin/env bash +set -euo pipefail + +# ============================================================ +# BentoPDF Air-Gapped Deployment Preparation Script +# ============================================================ +# Automates the creation of a self-contained deployment bundle +# for air-gapped (offline) networks. +# +# Run this on a machine WITH internet access. The output bundle +# contains everything needed to deploy BentoPDF offline. +# +# Usage: +# bash scripts/prepare-airgap.sh --wasm-base-url https://internal.example.com/wasm +# bash scripts/prepare-airgap.sh # interactive mode +# bash scripts/prepare-airgap.sh --ocr-languages eng,deu,fra +# bash scripts/prepare-airgap.sh --search-ocr-language german +# +# See --help for all options. +# ============================================================ + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +# --- Output formatting --- +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +BOLD='\033[1m' +NC='\033[0m' + +info() { echo -e "${BLUE}[INFO]${NC} $*"; } +success() { echo -e "${GREEN}[OK]${NC} $*"; } +warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } +error() { echo -e "${RED}[ERR]${NC} $*" >&2; } +step() { echo -e "\n${BOLD}==> $*${NC}"; } + +# Disable colors if NO_COLOR is set +if [ -n "${NO_COLOR:-}" ]; then + RED='' GREEN='' YELLOW='' BLUE='' BOLD='' NC='' +fi + +# --- Defaults --- +WASM_BASE_URL="" +IMAGE_NAME="bentopdf" +OUTPUT_DIR="./bentopdf-airgap-bundle" +SIMPLE_MODE="" +BASE_URL="" +COMPRESSION_MODE="" +LANGUAGE="" +BRAND_NAME="" +BRAND_LOGO="" +FOOTER_TEXT="" +DOCKERFILE="Dockerfile" +SKIP_DOCKER=false +SKIP_WASM=false +INTERACTIVE=false +OCR_LANGUAGES="eng" +TESSDATA_VERSION="4.0.0_best_int" +LIST_OCR_LANGUAGES=false +SEARCH_OCR_LANGUAGE_TERM="" + +TESSERACT_LANGUAGE_CONFIG="src/js/config/tesseract-languages.ts" +FONT_MAPPING_CONFIG="src/js/config/font-mappings.ts" + +SUPPORTED_OCR_LANGUAGES_RAW="" +OCR_FONT_MANIFEST_RAW="" + +load_supported_ocr_languages() { + if [ -n "$SUPPORTED_OCR_LANGUAGES_RAW" ]; then + return + fi + + if [ ! -f "$TESSERACT_LANGUAGE_CONFIG" ]; then + error "Missing OCR language config: ${TESSERACT_LANGUAGE_CONFIG}" + exit 1 + fi + + SUPPORTED_OCR_LANGUAGES_RAW=$(node -e "const fs = require('fs'); const source = fs.readFileSync(process.argv[1], 'utf8'); const languages = []; const pattern = /^\\s*([a-z0-9_]+):\\s*'([^']+)'/gm; let match; while ((match = pattern.exec(source)) !== null) { languages.push(match[1] + '\\t' + match[2]); } process.stdout.write(languages.join('\\n'));" "$TESSERACT_LANGUAGE_CONFIG") + + if [ -z "$SUPPORTED_OCR_LANGUAGES_RAW" ]; then + error "Failed to load supported OCR languages from ${TESSERACT_LANGUAGE_CONFIG}" + exit 1 + fi +} + +is_supported_ocr_language() { + local code="$1" + load_supported_ocr_languages + printf '%s\n' "$SUPPORTED_OCR_LANGUAGES_RAW" | awk -F '\t' -v code="$code" '$1 == code { found = 1 } END { exit found ? 0 : 1 }' +} + +show_supported_ocr_languages() { + load_supported_ocr_languages + + echo "" + echo -e "${BOLD}Supported OCR languages:${NC}" + echo " Use the code in the left column for --ocr-languages." + echo "" + printf '%s\n' "$SUPPORTED_OCR_LANGUAGES_RAW" | awk -F '\t' '{ printf " %-12s %s\n", $1, $2 }' + echo "" + echo " Example: --ocr-languages eng,deu,fra,spa" + echo "" +} + +show_matching_ocr_languages() { + local query="$1" + load_supported_ocr_languages + + if [ -z "$query" ]; then + error "OCR language search requires a non-empty query." + exit 1 + fi + + local matches + matches=$(printf '%s\n' "$SUPPORTED_OCR_LANGUAGES_RAW" | awk -F '\t' -v query="$query" ' + BEGIN { + normalized = tolower(query) + } + { + code = tolower($1) + name = tolower($2) + if (index(code, normalized) || index(name, normalized)) { + printf "%s\t%s\n", $1, $2 + } + } + ') + + echo "" + echo -e "${BOLD}OCR language search:${NC} ${query}" + + if [ -z "$matches" ]; then + echo " No supported OCR languages matched that query." + echo " Tip: run --list-ocr-languages to browse the full list." + echo "" + return 1 + fi + + echo " Matching codes for --ocr-languages:" + echo "" + printf '%s\n' "$matches" | awk -F '\t' '{ printf " %-12s %s\n", $1, $2 }' + echo "" +} + +load_required_ocr_fonts() { + if [ -n "$OCR_FONT_MANIFEST_RAW" ]; then + return + fi + + if [ ! -f "$FONT_MAPPING_CONFIG" ]; then + error "Missing OCR font mapping config: ${FONT_MAPPING_CONFIG}" + exit 1 + fi + + OCR_FONT_MANIFEST_RAW=$(node -e "const fs = require('fs'); const source = fs.readFileSync(process.argv[1], 'utf8'); const selected = (process.argv[2] || '').split(',').map((value) => value.trim()).filter(Boolean); const sections = source.split('export const fontFamilyToUrl'); const languageSection = sections[0] || ''; const fontSection = sections[1] || ''; const languageToFamily = {}; const fontFamilyToUrl = {}; let match; const languagePattern = /^\s*([a-z_]+):\s*'([^']+)',/gm; while ((match = languagePattern.exec(languageSection)) !== null) { languageToFamily[match[1]] = match[2]; } const fontPattern = /^\s*'([^']+)':\s*'([^']+)',/gm; while ((match = fontPattern.exec(fontSection)) !== null) { fontFamilyToUrl[match[1]] = match[2]; } const families = new Set(['Noto Sans']); for (const lang of selected) { families.add(languageToFamily[lang] || 'Noto Sans'); } const lines = Array.from(families).sort().map((family) => { const url = fontFamilyToUrl[family] || fontFamilyToUrl['Noto Sans']; const fileName = url.split('/').pop(); return [family, url, fileName].join('\t'); }); process.stdout.write(lines.join('\n'));" "$FONT_MAPPING_CONFIG" "$OCR_LANGUAGES") + + if [ -z "$OCR_FONT_MANIFEST_RAW" ]; then + error "Failed to resolve OCR font assets from ${FONT_MAPPING_CONFIG}" + exit 1 + fi +} + +# --- Usage --- +usage() { + cat <<'EOF' +BentoPDF Air-Gapped Deployment Preparation + +USAGE: + bash scripts/prepare-airgap.sh [OPTIONS] + bash scripts/prepare-airgap.sh # interactive mode + +REQUIRED: + --wasm-base-url Base URL where WASM files will be hosted + in the air-gapped network + (e.g. https://internal.example.com/wasm) + +OPTIONS: + --image-name Docker image name (default: bentopdf) + --output-dir Output bundle directory (default: ./bentopdf-airgap-bundle) + --dockerfile Dockerfile to use (default: Dockerfile) + --simple-mode Enable Simple Mode + --base-url Subdirectory base URL (e.g. /pdf/) + --compression Compression: g, b, o, all (default: all) + --language Default UI language (e.g. fr, de, es) + --brand-name Custom brand name + --brand-logo Logo path relative to public/ + --footer-text Custom footer text + --ocr-languages Comma-separated OCR languages to bundle + (default: eng) + --list-ocr-languages Print supported OCR language codes and exit + --search-ocr-language Search supported OCR languages by code or name + --skip-docker Skip Docker build and export + --skip-wasm Skip WASM download (reuse existing .tgz files) + --help Show this help message + +EXAMPLES: + # Minimal (prompts for WASM URL interactively) + bash scripts/prepare-airgap.sh + + # Full automation + bash scripts/prepare-airgap.sh \ + --wasm-base-url https://internal.example.com/wasm \ + --ocr-languages eng,deu,fra \ + --brand-name "AcmePDF" \ + --language fr + + # Skip Docker build (reuse existing image) + bash scripts/prepare-airgap.sh \ + --wasm-base-url https://internal.example.com/wasm \ + --skip-docker + + # Show all supported OCR language codes + bash scripts/prepare-airgap.sh --list-ocr-languages + + # Search OCR languages by code or human-readable name + bash scripts/prepare-airgap.sh --search-ocr-language german +EOF + exit 0 +} + +# --- Parse arguments --- +while [[ $# -gt 0 ]]; do + case "$1" in + --wasm-base-url) WASM_BASE_URL="$2"; shift 2 ;; + --image-name) IMAGE_NAME="$2"; shift 2 ;; + --output-dir) OUTPUT_DIR="$2"; shift 2 ;; + --simple-mode) SIMPLE_MODE="true"; shift ;; + --base-url) BASE_URL="$2"; shift 2 ;; + --compression) COMPRESSION_MODE="$2"; shift 2 ;; + --language) LANGUAGE="$2"; shift 2 ;; + --brand-name) BRAND_NAME="$2"; shift 2 ;; + --brand-logo) BRAND_LOGO="$2"; shift 2 ;; + --footer-text) FOOTER_TEXT="$2"; shift 2 ;; + --ocr-languages) OCR_LANGUAGES="$2"; shift 2 ;; + --list-ocr-languages) LIST_OCR_LANGUAGES=true; shift ;; + --search-ocr-language) SEARCH_OCR_LANGUAGE_TERM="$2"; shift 2 ;; + --dockerfile) DOCKERFILE="$2"; shift 2 ;; + --skip-docker) SKIP_DOCKER=true; shift ;; + --skip-wasm) SKIP_WASM=true; shift ;; + --help|-h) usage ;; + *) error "Unknown option: $1"; echo "Run with --help for usage."; exit 1 ;; + esac +done + +# --- Validate project root --- +cd "$PROJECT_ROOT" + +if [ ! -f "package.json" ] || [ ! -f "src/js/const/cdn-version.ts" ]; then + error "This script must be run from the BentoPDF project root." + error "Expected to find package.json and src/js/const/cdn-version.ts" + exit 1 +fi + +if [ "$LIST_OCR_LANGUAGES" = true ]; then + show_supported_ocr_languages + exit 0 +fi + +if [ -n "$SEARCH_OCR_LANGUAGE_TERM" ]; then + if show_matching_ocr_languages "$SEARCH_OCR_LANGUAGE_TERM"; then + exit 0 + fi + exit 1 +fi + +# --- Check prerequisites --- +check_prerequisites() { + local missing=false + + if ! command -v npm &>/dev/null; then + error "npm is required but not found. Install Node.js first." + missing=true + fi + + if [ "$SKIP_WASM" = false ] && ! command -v curl &>/dev/null; then + error "curl is required to download OCR language data." + missing=true + fi + + if [ "$SKIP_DOCKER" = false ] && ! command -v docker &>/dev/null; then + error "docker is required but not found (use --skip-docker to skip)." + missing=true + fi + + if [ "$missing" = true ]; then + exit 1 + fi +} + +# --- Read versions from source code --- +read_versions() { + PYMUPDF_VERSION=$(grep "pymupdf:" src/js/const/cdn-version.ts | grep -o "'[^']*'" | tr -d "'") + GS_VERSION=$(grep "ghostscript:" src/js/const/cdn-version.ts | grep -o "'[^']*'" | tr -d "'") + APP_VERSION=$(node -p "require('./package.json').version") + TESSERACT_VERSION=$(node -p "require('./package-lock.json').packages['node_modules/tesseract.js'].version") + TESSERACT_CORE_VERSION=$(node -p "require('./package-lock.json').packages['node_modules/tesseract.js-core'].version") + + if [ -z "$PYMUPDF_VERSION" ] || [ -z "$GS_VERSION" ] || [ -z "$TESSERACT_VERSION" ] || [ -z "$TESSERACT_CORE_VERSION" ]; then + error "Failed to read external asset versions from the repository metadata" + exit 1 + fi +} + +# --- Interactive mode --- +interactive_mode() { + echo "" + echo -e "${BOLD}============================================================${NC}" + echo -e "${BOLD} BentoPDF Air-Gapped Deployment Preparation${NC}" + echo -e "${BOLD} App Version: ${APP_VERSION}${NC}" + echo -e "${BOLD}============================================================${NC}" + echo "" + echo " Detected WASM versions from source:" + echo " PyMuPDF: ${PYMUPDF_VERSION}" + echo " Ghostscript: ${GS_VERSION}" + echo " CoherentPDF: latest" + echo " Tesseract.js: ${TESSERACT_VERSION}" + echo " OCR Data: ${TESSDATA_VERSION}" + echo "" + + # [1] WASM base URL (REQUIRED) + echo -e "${BOLD}[1/8] WASM Base URL ${RED}(required)${NC}" + echo " The URL where WASM files will be hosted inside the air-gapped network." + echo " The script will append /pymupdf/, /gs/, /cpdf/ to this URL." + echo "" + echo " Examples:" + echo " https://internal.example.com/wasm" + echo " http://192.168.1.100/assets/wasm" + echo " https://cdn.mycompany.local/bentopdf" + echo "" + while true; do + read -r -p " URL: " WASM_BASE_URL + if [ -z "$WASM_BASE_URL" ]; then + warn "WASM base URL is required. Please enter a URL." + elif [[ ! "$WASM_BASE_URL" =~ ^https?:// ]]; then + warn "Must start with http:// or https://. Try again." + else + break + fi + done + echo "" + + # [2] Docker image name (optional) + echo -e "${BOLD}[2/8] Docker Image Name ${GREEN}(optional)${NC}" + echo " The name used to tag the Docker image (used with 'docker run')." + read -r -p " Image name [${IMAGE_NAME}]: " input + IMAGE_NAME="${input:-$IMAGE_NAME}" + echo "" + + # [3] Simple mode (optional) + echo -e "${BOLD}[3/8] Simple Mode ${GREEN}(optional)${NC}" + echo " Hides navigation, hero, features, FAQ — shows only PDF tools." + read -r -p " Enable Simple Mode? (y/N): " input + if [[ "${input:-}" =~ ^[Yy]$ ]]; then + SIMPLE_MODE="true" + fi + echo "" + + # [4] Default language (optional) + echo -e "${BOLD}[4/8] Default UI Language ${GREEN}(optional)${NC}" + echo " Supported: en, ar, be, da, de, es, fr, id, it, nl, pt, tr, vi, zh, zh-TW" + while true; do + read -r -p " Language [en]: " input + LANGUAGE="${input:-}" + if [ -z "$LANGUAGE" ] || echo " en ar be da de es fr id it nl pt tr vi zh zh-TW " | grep -q " $LANGUAGE "; then + break + fi + warn "Invalid language code '${LANGUAGE}'. Try again." + done + echo "" + + # [5] Custom branding (optional) + echo -e "${BOLD}[5/8] Custom Branding ${GREEN}(optional)${NC}" + echo " Replace the default BentoPDF name, logo, and footer text." + read -r -p " Brand name [BentoPDF]: " input + BRAND_NAME="${input:-}" + if [ -n "$BRAND_NAME" ]; then + echo " Place your logo in the public/ folder before building." + read -r -p " Logo path relative to public/ [images/favicon-no-bg.svg]: " input + BRAND_LOGO="${input:-}" + read -r -p " Footer text [© 2026 BentoPDF. All rights reserved.]: " input + FOOTER_TEXT="${input:-}" + fi + echo "" + + # [6] Base URL (optional) + echo -e "${BOLD}[6/8] Base URL ${GREEN}(optional)${NC}" + echo " Set this if hosting under a subdirectory (e.g. /pdf/)." + read -r -p " Base URL [/]: " input + BASE_URL="${input:-}" + echo "" + + # [7] Dockerfile (optional) + echo -e "${BOLD}[7/8] Dockerfile ${GREEN}(optional)${NC}" + echo " Options: Dockerfile (standard) or Dockerfile.nonroot (custom PUID/PGID)" + read -r -p " Dockerfile [${DOCKERFILE}]: " input + DOCKERFILE="${input:-$DOCKERFILE}" + echo "" + + # [8] OCR languages (optional) + echo -e "${BOLD}[8/9] OCR Languages ${GREEN}(optional)${NC}" + echo " Comma-separated traineddata files to bundle for offline OCR." + echo " Enter Tesseract language codes such as: eng,deu,fra,spa" + echo " Type 'list' to print the full supported language list." + echo " Type 'search ' to find codes by name or abbreviation." + while true; do + read -r -p " OCR languages [${OCR_LANGUAGES}]: " input + if [ -z "${input:-}" ]; then + break + fi + if [ "$input" = "list" ]; then + show_supported_ocr_languages + continue + fi + if [[ "$input" == search\ * ]]; then + search_query="${input#search }" + if ! show_matching_ocr_languages "$search_query"; then + warn "No OCR language matched '${search_query}'." + fi + continue + fi + OCR_LANGUAGES="$input" + break + done + echo "" + + # [9] Output directory (optional) + echo -e "${BOLD}[9/9] Output Directory ${GREEN}(optional)${NC}" + read -r -p " Path [${OUTPUT_DIR}]: " input + OUTPUT_DIR="${input:-$OUTPUT_DIR}" + + # Confirm + echo "" + echo -e "${BOLD}--- Configuration Summary ---${NC}" + echo "" + echo " WASM Base URL: ${WASM_BASE_URL}" + echo " Image Name: ${IMAGE_NAME}" + echo " Dockerfile: ${DOCKERFILE}" + echo " Simple Mode: ${SIMPLE_MODE:-false}" + echo " Language: ${LANGUAGE:-en (default)}" + echo " Brand Name: ${BRAND_NAME:-BentoPDF (default)}" + [ -n "$BRAND_NAME" ] && echo " Brand Logo: ${BRAND_LOGO:-images/favicon-no-bg.svg (default)}" + [ -n "$BRAND_NAME" ] && echo " Footer Text: ${FOOTER_TEXT:-(default)}" + echo " Base URL: ${BASE_URL:-/ (root)}" + echo " OCR Languages: ${OCR_LANGUAGES}" + echo " Output: ${OUTPUT_DIR}" + echo "" + read -r -p " Proceed? (Y/n): " input + if [[ "${input:-Y}" =~ ^[Nn]$ ]]; then + echo "Aborted." + exit 0 + fi +} + +# --- SHA-256 checksum (cross-platform) --- +sha256() { + if command -v sha256sum &>/dev/null; then + sha256sum "$1" | awk '{print $1}' + elif command -v shasum &>/dev/null; then + shasum -a 256 "$1" | awk '{print $1}' + else + echo "n/a" + fi +} + +# --- File size (human-readable, cross-platform) --- +filesize() { + if stat --version &>/dev/null 2>&1; then + # GNU stat (Linux) + stat --printf='%s' "$1" 2>/dev/null | awk '{ + if ($1 >= 1073741824) printf "%.1f GB", $1/1073741824; + else if ($1 >= 1048576) printf "%.1f MB", $1/1048576; + else if ($1 >= 1024) printf "%.1f KB", $1/1024; + else printf "%d B", $1; + }' + else + # BSD stat (macOS) + stat -f '%z' "$1" 2>/dev/null | awk '{ + if ($1 >= 1073741824) printf "%.1f GB", $1/1073741824; + else if ($1 >= 1048576) printf "%.1f MB", $1/1048576; + else if ($1 >= 1024) printf "%.1f KB", $1/1024; + else printf "%d B", $1; + }' + fi +} + +# ============================================================ +# MAIN +# ============================================================ + +check_prerequisites +read_versions +load_supported_ocr_languages + +# If no WASM base URL provided, go interactive +if [ -z "$WASM_BASE_URL" ]; then + INTERACTIVE=true + interactive_mode +fi + +# Validate language code if provided +if [ -n "$LANGUAGE" ]; then + VALID_LANGS="en ar be da de es fr id it nl pt tr vi zh zh-TW" + if ! echo " $VALID_LANGS " | grep -q " $LANGUAGE "; then + error "Invalid language code: ${LANGUAGE}" + error "Supported: ${VALID_LANGS}" + exit 1 + fi +fi + +IFS=',' read -r -a OCR_LANGUAGE_ARRAY <<< "$OCR_LANGUAGES" +NORMALIZED_OCR_LANGUAGES=() +for raw_lang in "${OCR_LANGUAGE_ARRAY[@]}"; do + lang=$(echo "$raw_lang" | tr -d '[:space:]') + if [ -z "$lang" ]; then + continue + fi + if [[ ! "$lang" =~ ^[a-z0-9_]+$ ]]; then + error "Invalid OCR language code: ${lang}" + error "Use comma-separated Tesseract codes such as eng,deu,fra,chi_sim" + exit 1 + fi + if ! is_supported_ocr_language "$lang"; then + error "Unsupported OCR language code: ${lang}" + error "Run with --list-ocr-languages or --search-ocr-language to find supported Tesseract codes." + exit 1 + fi + NORMALIZED_OCR_LANGUAGES+=("$lang") +done + +if [ ${#NORMALIZED_OCR_LANGUAGES[@]} -eq 0 ]; then + error "At least one OCR language must be included." + exit 1 +fi + +OCR_LANGUAGES=$(IFS=','; echo "${NORMALIZED_OCR_LANGUAGES[*]}") +load_required_ocr_fonts + +# Validate WASM base URL format +if [[ ! "$WASM_BASE_URL" =~ ^https?:// ]]; then + error "WASM base URL must start with http:// or https://" + error " Got: ${WASM_BASE_URL}" + error " Example: https://internal.example.com/wasm" + exit 1 +fi + +# Strip trailing slash from WASM base URL +WASM_BASE_URL="${WASM_BASE_URL%/}" + +# Construct WASM URLs +WASM_PYMUPDF_URL="${WASM_BASE_URL}/pymupdf/" +WASM_GS_URL="${WASM_BASE_URL}/gs/" +WASM_CPDF_URL="${WASM_BASE_URL}/cpdf/" +OCR_TESSERACT_WORKER_URL="${WASM_BASE_URL}/ocr/worker.min.js" +OCR_TESSERACT_CORE_URL="${WASM_BASE_URL}/ocr/core" +OCR_TESSERACT_LANG_URL="${WASM_BASE_URL}/ocr/lang-data" +OCR_FONT_BASE_URL="${WASM_BASE_URL}/ocr/fonts" + +echo "" +echo -e "${BOLD}============================================================${NC}" +echo -e "${BOLD} BentoPDF Air-Gapped Bundle Preparation${NC}" +echo -e "${BOLD} App: v${APP_VERSION} | PyMuPDF: ${PYMUPDF_VERSION} | GS: ${GS_VERSION} | OCR: ${TESSERACT_VERSION}${NC}" +echo -e "${BOLD}============================================================${NC}" + +# --- Phase 1: Prepare output directory --- +step "Preparing output directory" + +mkdir -p "$OUTPUT_DIR" +OUTPUT_DIR="$(cd "$OUTPUT_DIR" && pwd)" + +# Warn if output directory already has bundle files +if ls "$OUTPUT_DIR"/*.tgz "$OUTPUT_DIR"/bentopdf.tar "$OUTPUT_DIR"/setup.sh 2>/dev/null | head -1 &>/dev/null; then + warn "Output directory already contains files from a previous run." + warn "Existing files will be overwritten." + if [ "$INTERACTIVE" = true ]; then + read -r -p " Continue? (Y/n): " input + if [[ "${input:-Y}" =~ ^[Nn]$ ]]; then + echo "Aborted." + exit 0 + fi + fi +fi + +info "Output: ${OUTPUT_DIR}" + +# --- Phase 2: Download WASM packages --- +if [ "$SKIP_WASM" = true ]; then + step "Skipping WASM download (--skip-wasm)" + # Verify each file exists with specific errors + wasm_missing=false + if ! ls "$OUTPUT_DIR"/bentopdf-pymupdf-wasm-*.tgz &>/dev/null; then + error "Missing: bentopdf-pymupdf-wasm-*.tgz" + wasm_missing=true + fi + if ! ls "$OUTPUT_DIR"/bentopdf-gs-wasm-*.tgz &>/dev/null; then + error "Missing: bentopdf-gs-wasm-*.tgz" + wasm_missing=true + fi + if ! ls "$OUTPUT_DIR"/coherentpdf-*.tgz &>/dev/null; then + error "Missing: coherentpdf-*.tgz" + wasm_missing=true + fi + if ! ls "$OUTPUT_DIR"/tesseract.js-*.tgz &>/dev/null; then + error "Missing: tesseract.js-*.tgz" + wasm_missing=true + fi + if ! ls "$OUTPUT_DIR"/tesseract.js-core-*.tgz &>/dev/null; then + error "Missing: tesseract.js-core-*.tgz" + wasm_missing=true + fi + for lang in "${NORMALIZED_OCR_LANGUAGES[@]}"; do + if [ ! -f "$OUTPUT_DIR/tesseract-langdata/${lang}.traineddata.gz" ]; then + error "Missing: tesseract-langdata/${lang}.traineddata.gz" + wasm_missing=true + fi + done + while IFS=$'\t' read -r font_family font_url font_file; do + [ -z "$font_file" ] && continue + if [ ! -f "$OUTPUT_DIR/ocr-fonts/${font_file}" ]; then + error "Missing: ocr-fonts/${font_file} (${font_family})" + wasm_missing=true + fi + done <<< "$OCR_FONT_MANIFEST_RAW" + if [ "$wasm_missing" = true ]; then + error "Run without --skip-wasm first to download the packages." + exit 1 + fi + success "Reusing existing WASM packages" +else + step "Downloading WASM packages" + + WASM_TMP=$(mktemp -d) + trap 'rm -rf "$WASM_TMP"' EXIT + + info "Downloading @bentopdf/pymupdf-wasm@${PYMUPDF_VERSION}..." + if ! (cd "$WASM_TMP" && npm pack "@bentopdf/pymupdf-wasm@${PYMUPDF_VERSION}" --quiet 2>&1); then + error "Failed to download @bentopdf/pymupdf-wasm@${PYMUPDF_VERSION}" + error "Check your internet connection and that the package exists on npm." + exit 1 + fi + + info "Downloading @bentopdf/gs-wasm@${GS_VERSION}..." + if ! (cd "$WASM_TMP" && npm pack "@bentopdf/gs-wasm@${GS_VERSION}" --quiet 2>&1); then + error "Failed to download @bentopdf/gs-wasm@${GS_VERSION}" + error "Check your internet connection and that the package exists on npm." + exit 1 + fi + + info "Downloading coherentpdf..." + if ! (cd "$WASM_TMP" && npm pack coherentpdf --quiet 2>&1); then + error "Failed to download coherentpdf" + error "Check your internet connection and that the package exists on npm." + exit 1 + fi + + info "Downloading tesseract.js@${TESSERACT_VERSION}..." + if ! (cd "$WASM_TMP" && npm pack "tesseract.js@${TESSERACT_VERSION}" --quiet 2>&1); then + error "Failed to download tesseract.js@${TESSERACT_VERSION}" + exit 1 + fi + + info "Downloading tesseract.js-core@${TESSERACT_CORE_VERSION}..." + if ! (cd "$WASM_TMP" && npm pack "tesseract.js-core@${TESSERACT_CORE_VERSION}" --quiet 2>&1); then + error "Failed to download tesseract.js-core@${TESSERACT_CORE_VERSION}" + exit 1 + fi + + # Move to output directory + mv "$WASM_TMP"/*.tgz "$OUTPUT_DIR/" + + mkdir -p "$OUTPUT_DIR/tesseract-langdata" + for lang in "${NORMALIZED_OCR_LANGUAGES[@]}"; do + info "Downloading OCR language data: ${lang}..." + if ! curl -fsSL "https://cdn.jsdelivr.net/npm/@tesseract.js-data/${lang}/${TESSDATA_VERSION}/${lang}.traineddata.gz" -o "$OUTPUT_DIR/tesseract-langdata/${lang}.traineddata.gz"; then + error "Failed to download OCR language data for ${lang}" + error "Check that the language code exists and that the network can reach jsDelivr." + exit 1 + fi + done + + mkdir -p "$OUTPUT_DIR/ocr-fonts" + while IFS=$'\t' read -r font_family font_url font_file; do + [ -z "$font_file" ] && continue + info "Downloading OCR font: ${font_family}..." + if ! curl -fsSL "$font_url" -o "$OUTPUT_DIR/ocr-fonts/${font_file}"; then + error "Failed to download OCR font '${font_family}'" + error "Check that the network can reach the font URL: ${font_url}" + exit 1 + fi + done <<< "$OCR_FONT_MANIFEST_RAW" + + rm -rf "$WASM_TMP" + trap - EXIT + + # Resolve CoherentPDF version from filename + CPDF_TGZ=$(ls "$OUTPUT_DIR"/coherentpdf-*.tgz 2>/dev/null | head -1) + CPDF_VERSION=$(basename "$CPDF_TGZ" | sed 's/coherentpdf-\(.*\)\.tgz/\1/') + + success "Downloaded all WASM packages" + info " PyMuPDF: $(filesize "$OUTPUT_DIR"/bentopdf-pymupdf-wasm-*.tgz)" + info " Ghostscript: $(filesize "$OUTPUT_DIR"/bentopdf-gs-wasm-*.tgz)" + info " CoherentPDF: $(filesize "$CPDF_TGZ") (v${CPDF_VERSION})" + info " Tesseract.js: $(filesize "$OUTPUT_DIR"/tesseract.js-*.tgz)" + info " OCR Core: $(filesize "$OUTPUT_DIR"/tesseract.js-core-*.tgz)" + info " OCR Langs: ${OCR_LANGUAGES}" + info " OCR Fonts: $(printf '%s\n' "$OCR_FONT_MANIFEST_RAW" | awk -F '\t' 'NF >= 1 { print $1 }' | paste -sd ', ' -)" +fi + +# Resolve CPDF version if we skipped download +if [ -z "${CPDF_VERSION:-}" ]; then + CPDF_TGZ=$(ls "$OUTPUT_DIR"/coherentpdf-*.tgz 2>/dev/null | head -1) + CPDF_VERSION=$(basename "$CPDF_TGZ" | sed 's/coherentpdf-\(.*\)\.tgz/\1/') +fi + +# --- Phase 3: Build Docker image --- +if [ "$SKIP_DOCKER" = true ]; then + step "Skipping Docker build (--skip-docker)" + + # Check if image exists or tar exists + if [ -f "$OUTPUT_DIR/bentopdf.tar" ]; then + success "Reusing existing bentopdf.tar" + elif docker image inspect "$IMAGE_NAME" &>/dev/null; then + step "Exporting existing Docker image" + docker save "$IMAGE_NAME" -o "$OUTPUT_DIR/bentopdf.tar" + success "Exported: $(filesize "$OUTPUT_DIR/bentopdf.tar")" + else + warn "No Docker image '${IMAGE_NAME}' found and no bentopdf.tar in output." + warn "The bundle will not include a Docker image." + fi +else + step "Building Docker image" + + # Verify Dockerfile exists + if [ ! -f "$DOCKERFILE" ]; then + error "Dockerfile not found: ${DOCKERFILE}" + error "Available Dockerfiles:" + ls -1 Dockerfile* 2>/dev/null | sed 's/^/ /' || echo " (none found)" + exit 1 + fi + + # Verify Docker daemon is running + if ! docker info &>/dev/null; then + error "Docker daemon is not running. Start Docker and try again." + exit 1 + fi + + # Build the docker build command + BUILD_ARGS=() + BUILD_ARGS+=(--build-arg "VITE_WASM_PYMUPDF_URL=${WASM_PYMUPDF_URL}") + BUILD_ARGS+=(--build-arg "VITE_WASM_GS_URL=${WASM_GS_URL}") + BUILD_ARGS+=(--build-arg "VITE_WASM_CPDF_URL=${WASM_CPDF_URL}") + BUILD_ARGS+=(--build-arg "VITE_TESSERACT_WORKER_URL=${OCR_TESSERACT_WORKER_URL}") + BUILD_ARGS+=(--build-arg "VITE_TESSERACT_CORE_URL=${OCR_TESSERACT_CORE_URL}") + BUILD_ARGS+=(--build-arg "VITE_TESSERACT_LANG_URL=${OCR_TESSERACT_LANG_URL}") + BUILD_ARGS+=(--build-arg "VITE_TESSERACT_AVAILABLE_LANGUAGES=${OCR_LANGUAGES}") + BUILD_ARGS+=(--build-arg "VITE_OCR_FONT_BASE_URL=${OCR_FONT_BASE_URL}") + + [ -n "$SIMPLE_MODE" ] && BUILD_ARGS+=(--build-arg "SIMPLE_MODE=${SIMPLE_MODE}") + [ -n "$BASE_URL" ] && BUILD_ARGS+=(--build-arg "BASE_URL=${BASE_URL}") + [ -n "$COMPRESSION_MODE" ] && BUILD_ARGS+=(--build-arg "COMPRESSION_MODE=${COMPRESSION_MODE}") + [ -n "$LANGUAGE" ] && BUILD_ARGS+=(--build-arg "VITE_DEFAULT_LANGUAGE=${LANGUAGE}") + [ -n "$BRAND_NAME" ] && BUILD_ARGS+=(--build-arg "VITE_BRAND_NAME=${BRAND_NAME}") + [ -n "$BRAND_LOGO" ] && BUILD_ARGS+=(--build-arg "VITE_BRAND_LOGO=${BRAND_LOGO}") + [ -n "$FOOTER_TEXT" ] && BUILD_ARGS+=(--build-arg "VITE_FOOTER_TEXT=${FOOTER_TEXT}") + + info "Image name: ${IMAGE_NAME}" + info "Dockerfile: ${DOCKERFILE}" + info "WASM URLs:" + info " PyMuPDF: ${WASM_PYMUPDF_URL}" + info " Ghostscript: ${WASM_GS_URL}" + info " CoherentPDF: ${WASM_CPDF_URL}" + info "OCR URLs:" + info " Worker: ${OCR_TESSERACT_WORKER_URL}" + info " Core: ${OCR_TESSERACT_CORE_URL}" + info " Lang Data: ${OCR_TESSERACT_LANG_URL}" + info " Font Base: ${OCR_FONT_BASE_URL}" + info " Languages: ${OCR_LANGUAGES}" + echo "" + info "Building... this may take a few minutes (npm install + Vite build)." + echo "" + + docker build -f "$DOCKERFILE" "${BUILD_ARGS[@]}" -t "$IMAGE_NAME" . + + success "Docker image '${IMAGE_NAME}' built successfully" + + # --- Phase 4: Export Docker image --- + step "Exporting Docker image" + + docker save "$IMAGE_NAME" -o "$OUTPUT_DIR/bentopdf.tar" + success "Exported: $(filesize "$OUTPUT_DIR/bentopdf.tar")" +fi + +# --- Phase 5: Generate setup.sh --- +step "Generating setup script" + +cat > "$OUTPUT_DIR/setup.sh" </dev/null; then + echo "ERROR: docker is required but not found." + echo "Install Docker first: https://docs.docker.com/get-docker/" + exit 1 +fi + +if ! docker info &>/dev/null; then + echo "ERROR: Docker daemon is not running. Start Docker and try again." + exit 1 +fi + +# --- Configuration (baked in at generation time) --- +IMAGE_NAME="${IMAGE_NAME}" +WASM_BASE_URL="${WASM_BASE_URL}" +DOCKER_PORT="\${1:-3000}" + +# Where to extract WASM files (override with WASM_EXTRACT_DIR env var) +WASM_DIR="\${WASM_EXTRACT_DIR:-\${SCRIPT_DIR}/wasm}" + +echo "" +echo "============================================================" +echo " BentoPDF Air-Gapped Setup" +echo " Version: ${APP_VERSION}" +echo "============================================================" +echo "" +echo " Docker image: \${IMAGE_NAME}" +echo " WASM base URL: \${WASM_BASE_URL}" +echo " WASM extract: \${WASM_DIR}" +echo " Port: \${DOCKER_PORT}" +echo "" + +# --- Step 1: Load Docker Image --- +echo "[1/3] Loading Docker image..." +if [ -f "\${SCRIPT_DIR}/bentopdf.tar" ]; then + docker load -i "\${SCRIPT_DIR}/bentopdf.tar" + echo " Docker image '\${IMAGE_NAME}' loaded." +else + echo " WARNING: bentopdf.tar not found. Skipping Docker load." + echo " Make sure the image '\${IMAGE_NAME}' is already available." +fi + +# --- Step 2: Extract WASM Packages --- +echo "" +echo "[2/3] Extracting WASM packages to \${WASM_DIR}..." + +mkdir -p "\${WASM_DIR}/pymupdf" "\${WASM_DIR}/gs" "\${WASM_DIR}/cpdf" "\${WASM_DIR}/ocr/core" "\${WASM_DIR}/ocr/lang-data" "\${WASM_DIR}/ocr/fonts" + +# PyMuPDF: package has dist/ and assets/ at root +echo " Extracting PyMuPDF..." +tar xzf "\${SCRIPT_DIR}"/bentopdf-pymupdf-wasm-*.tgz -C "\${WASM_DIR}/pymupdf" --strip-components=1 + +# Ghostscript: browser expects gs.js and gs.wasm at root +echo " Extracting Ghostscript..." +TEMP_GS="\$(mktemp -d)" +tar xzf "\${SCRIPT_DIR}"/bentopdf-gs-wasm-*.tgz -C "\${TEMP_GS}" +if [ -d "\${TEMP_GS}/package/assets" ]; then + cp -r "\${TEMP_GS}/package/assets/"* "\${WASM_DIR}/gs/" +else + cp -r "\${TEMP_GS}/package/"* "\${WASM_DIR}/gs/" +fi +rm -rf "\${TEMP_GS}" + +# CoherentPDF: browser expects coherentpdf.browser.min.js at root +echo " Extracting CoherentPDF..." +TEMP_CPDF="\$(mktemp -d)" +tar xzf "\${SCRIPT_DIR}"/coherentpdf-*.tgz -C "\${TEMP_CPDF}" +if [ -d "\${TEMP_CPDF}/package/dist" ]; then + cp -r "\${TEMP_CPDF}/package/dist/"* "\${WASM_DIR}/cpdf/" +else + cp -r "\${TEMP_CPDF}/package/"* "\${WASM_DIR}/cpdf/" +fi +rm -rf "\${TEMP_CPDF}" + +# Tesseract worker: browser expects a single worker.min.js file +echo " Extracting Tesseract worker..." +TEMP_TESS="\$(mktemp -d)" +tar xzf "\${SCRIPT_DIR}"/tesseract.js-*.tgz -C "\${TEMP_TESS}" +cp "\${TEMP_TESS}/package/dist/worker.min.js" "\${WASM_DIR}/ocr/worker.min.js" +rm -rf "\${TEMP_TESS}" + +# Tesseract core: browser expects the full tesseract.js-core directory +echo " Extracting Tesseract core..." +tar xzf "\${SCRIPT_DIR}"/tesseract.js-core-*.tgz -C "\${WASM_DIR}/ocr/core" --strip-components=1 + +# OCR language data: copy the bundled traineddata files +echo " Installing OCR language data..." +cp "\${SCRIPT_DIR}"/tesseract-langdata/*.traineddata.gz "\${WASM_DIR}/ocr/lang-data/" + +# OCR fonts: copy the bundled font files for searchable text layer rendering +echo " Installing OCR fonts..." +cp "\${SCRIPT_DIR}"/ocr-fonts/* "\${WASM_DIR}/ocr/fonts/" + +echo " WASM files extracted to: \${WASM_DIR}" +echo "" +echo " IMPORTANT: Ensure these paths are served by your internal web server:" +echo " \${WASM_BASE_URL}/pymupdf/ -> \${WASM_DIR}/pymupdf/" +echo " \${WASM_BASE_URL}/gs/ -> \${WASM_DIR}/gs/" +echo " \${WASM_BASE_URL}/cpdf/ -> \${WASM_DIR}/cpdf/" +echo " \${WASM_BASE_URL}/ocr/worker.min.js -> \${WASM_DIR}/ocr/worker.min.js" +echo " \${WASM_BASE_URL}/ocr/core -> \${WASM_DIR}/ocr/core/" +echo " \${WASM_BASE_URL}/ocr/lang-data -> \${WASM_DIR}/ocr/lang-data/" +echo " \${WASM_BASE_URL}/ocr/fonts -> \${WASM_DIR}/ocr/fonts/" + +# --- Step 3: Start BentoPDF --- +echo "" +echo "[3/3] Ready to start BentoPDF" +echo "" +echo " To start manually:" +echo " docker run -d --name bentopdf -p \${DOCKER_PORT}:8080 --restart unless-stopped \${IMAGE_NAME}" +echo "" +echo " Then open: http://localhost:\${DOCKER_PORT}" +echo "" + +read -r -p "Start BentoPDF now? (y/N): " REPLY +if [[ "\${REPLY:-}" =~ ^[Yy]$ ]]; then + docker run -d --name bentopdf -p "\${DOCKER_PORT}:8080" --restart unless-stopped "\${IMAGE_NAME}" + echo "" + echo " BentoPDF is running at http://localhost:\${DOCKER_PORT}" +fi +SETUP_EOF + +chmod +x "$OUTPUT_DIR/setup.sh" +success "Generated setup.sh" + +# --- Phase 6: Generate README --- +step "Generating README" + +cat > "$OUTPUT_DIR/README.md" <= 3 { printf "- **%s** -> `%s`\n", $1, $3 }') + +These URLs are baked into the app at build time. The user's browser fetches +WASM files from these URLs at runtime. + +## Quick Setup + +Transfer this entire directory to the air-gapped network, then: + +\`\`\`bash +bash setup.sh +\`\`\` + +The setup script will: +1. Load the Docker image +2. Extract WASM packages to \`./wasm/\` (override with \`WASM_EXTRACT_DIR\`) +3. Optionally start the BentoPDF container + +## Manual Setup + +### 1. Load the Docker image + +\`\`\`bash +docker load -i bentopdf.tar +\`\`\` + +### 2. Extract WASM packages + +Extract to your internal web server's document root: + +\`\`\`bash +mkdir -p ./wasm/pymupdf ./wasm/gs ./wasm/cpdf ./wasm/ocr/core ./wasm/ocr/lang-data ./wasm/ocr/fonts + +# PyMuPDF +tar xzf bentopdf-pymupdf-wasm-${PYMUPDF_VERSION}.tgz -C ./wasm/pymupdf --strip-components=1 + +# Ghostscript (extract assets/ to root) +TEMP_GS=\$(mktemp -d) +tar xzf bentopdf-gs-wasm-${GS_VERSION}.tgz -C \$TEMP_GS +cp -r \$TEMP_GS/package/assets/* ./wasm/gs/ +rm -rf \$TEMP_GS + +# CoherentPDF (extract dist/ to root) +TEMP_CPDF=\$(mktemp -d) +tar xzf coherentpdf-${CPDF_VERSION}.tgz -C \$TEMP_CPDF +cp -r \$TEMP_CPDF/package/dist/* ./wasm/cpdf/ +rm -rf \$TEMP_CPDF + +# Tesseract worker +TEMP_TESS=\$(mktemp -d) +tar xzf tesseract.js-${TESSERACT_VERSION}.tgz -C \$TEMP_TESS +cp \$TEMP_TESS/package/dist/worker.min.js ./wasm/ocr/worker.min.js +rm -rf \$TEMP_TESS + +# Tesseract core +tar xzf tesseract.js-core-${TESSERACT_CORE_VERSION}.tgz -C ./wasm/ocr/core --strip-components=1 + +# OCR language data +cp ./tesseract-langdata/*.traineddata.gz ./wasm/ocr/lang-data/ + +# OCR fonts +cp ./ocr-fonts/* ./wasm/ocr/fonts/ +\`\`\` + +### 3. Configure your web server + +Ensure these paths are accessible at the configured URLs: + +| URL | Serves From | +| --- | --- | +| \`${WASM_PYMUPDF_URL}\` | \`./wasm/pymupdf/\` | +| \`${WASM_GS_URL}\` | \`./wasm/gs/\` | +| \`${WASM_CPDF_URL}\` | \`./wasm/cpdf/\` | +| \`${OCR_TESSERACT_WORKER_URL}\` | \`./wasm/ocr/worker.min.js\` | +| \`${OCR_TESSERACT_CORE_URL}\` | \`./wasm/ocr/core/\` | +| \`${OCR_TESSERACT_LANG_URL}\` | \`./wasm/ocr/lang-data/\` | +| \`${OCR_FONT_BASE_URL}\` | \`./wasm/ocr/fonts/\` | + +### 4. Run BentoPDF + +\`\`\`bash +docker run -d --name bentopdf -p 3000:8080 --restart unless-stopped ${IMAGE_NAME} +\`\`\` + +Open: http://localhost:3000 +README_EOF + +success "Generated README.md" + +# --- Phase 7: Summary --- +step "Bundle complete" + +echo "" +echo -e "${BOLD} Output: ${OUTPUT_DIR}${NC}" +echo "" +echo " Files:" +for f in "$OUTPUT_DIR"/*; do + fname=$(basename "$f") + fsize=$(filesize "$f") + echo " ${fname} (${fsize})" +done + +echo "" +echo -e "${BOLD} Next steps:${NC}" +echo " 1. Transfer the '$(basename "$OUTPUT_DIR")' directory to the air-gapped network" +echo " 2. Run: bash setup.sh" +echo " 3. Configure your internal web server to serve the WASM files" +echo "" +success "Done!" diff --git a/scripts/release.js b/scripts/release.js index 696024a..de257ae 100644 --- a/scripts/release.js +++ b/scripts/release.js @@ -80,7 +80,9 @@ function main() { execSync('npm run update-version', { stdio: 'inherit' }); // 3. Add and commit changes - execSync('git add package.json *.html src/pages/*.html', { stdio: 'inherit' }); + execSync('git add package.json *.html src/pages/*.html', { + stdio: 'inherit', + }); execSync(`git commit -m "Release v${newVersion}"`, { stdio: 'inherit' }); console.log(`💾 Committed version change`); @@ -98,7 +100,7 @@ function main() { execSync(`git push origin ${tagName}`, { stdio: 'inherit' }); console.log(`🎉 Release v${newVersion} complete!`); - console.log(`📦 Docker image: bentopdf/bentopdf:${newVersion}`); + console.log(`📦 Docker image: bentopdfteam/bentopdf:${newVersion}`); console.log(`📦 Distribution: dist-${newVersion}.zip`); console.log( `🏷️ GitHub release: https://github.com/alam00000/bentopdf/releases/tag/${tagName}` diff --git a/signatures/cla.json b/signatures/cla.json index d6ffa2a..9401687 100644 --- a/signatures/cla.json +++ b/signatures/cla.json @@ -287,6 +287,126 @@ "created_at": "2026-01-20T21:00:39Z", "repoId": 1074785178, "pullRequestNo": 410 + }, + { + "name": "pavel-miniutka", + "id": 9590440, + "comment_id": 3805290324, + "created_at": "2026-01-27T13:42:41Z", + "repoId": 1074785178, + "pullRequestNo": 432 + }, + { + "name": "TheScienceotter", + "id": 34091455, + "comment_id": 3840227722, + "created_at": "2026-02-03T09:45:16Z", + "repoId": 1074785178, + "pullRequestNo": 459 + }, + { + "name": "froeand", + "id": 259092694, + "comment_id": 3841449043, + "created_at": "2026-02-03T13:50:03Z", + "repoId": 1074785178, + "pullRequestNo": 461 + }, + { + "name": "tzabbi", + "id": 40226087, + "comment_id": 3926638030, + "created_at": "2026-02-19T11:27:36Z", + "repoId": 1074785178, + "pullRequestNo": 493 + }, + { + "name": "diogotcorreia", + "id": 7467891, + "comment_id": 3937759209, + "created_at": "2026-02-21T00:26:29Z", + "repoId": 1074785178, + "pullRequestNo": 501 + }, + { + "name": "hagibr", + "id": 23699887, + "comment_id": 3960964454, + "created_at": "2026-02-25T17:50:30Z", + "repoId": 1074785178, + "pullRequestNo": 516 + }, + { + "name": "maxbengtzen", + "id": 149163078, + "comment_id": 3972875117, + "created_at": "2026-02-27T13:07:34Z", + "repoId": 1074785178, + "pullRequestNo": 529 + }, + { + "name": "Olivetti", + "id": 1502093, + "comment_id": 3984507146, + "created_at": "2026-03-02T13:46:02Z", + "repoId": 1074785178, + "pullRequestNo": 531 + }, + { + "name": "InstaZDLL", + "id": 72951793, + "comment_id": 3984770306, + "created_at": "2026-03-02T14:33:11Z", + "repoId": 1074785178, + "pullRequestNo": 538 + }, + { + "name": "the0807", + "id": 73097985, + "comment_id": 4016739665, + "created_at": "2026-03-07T15:47:22Z", + "repoId": 1074785178, + "pullRequestNo": 552 + }, + { + "name": "iegl3", + "id": 51818383, + "comment_id": 4017736793, + "created_at": "2026-03-08T00:25:30Z", + "repoId": 1074785178, + "pullRequestNo": 553 + }, + { + "name": "xtotdam", + "id": 5108025, + "comment_id": 4019017905, + "created_at": "2026-03-08T13:05:16Z", + "repoId": 1074785178, + "pullRequestNo": 555 + }, + { + "name": "oleole39", + "id": 59071673, + "comment_id": 4027769205, + "created_at": "2026-03-10T00:20:15Z", + "repoId": 1074785178, + "pullRequestNo": 560 + }, + { + "name": "YuF-9468", + "id": 264538812, + "comment_id": 4043952570, + "created_at": "2026-03-12T04:50:23Z", + "repoId": 1074785178, + "pullRequestNo": 568 + }, + { + "name": "luna-cant-code", + "id": 223513004, + "comment_id": 4060580247, + "created_at": "2026-03-14T13:47:19Z", + "repoId": 1074785178, + "pullRequestNo": 575 } ] } \ No newline at end of file diff --git a/simple-index.html b/simple-index.html index 78b8fda..58311f4 100644 --- a/simple-index.html +++ b/simple-index.html @@ -56,12 +56,14 @@ @@ -329,6 +331,59 @@ > + +
+
+ +

+ Display tools as a compact list instead of cards +

+
+ +
+ + +
+ + Advanced Settings + +

+ Configure external processing modules (PyMuPDF, Ghostscript, + CoherentPDF) +

+
+ +
@@ -439,14 +494,17 @@
Bento PDF Logo - BentoPDF + {{#if brandName}}{{brandName}}{{else}}BentoPDF{{/if}}

- © 2026 BentoPDF. All rights reserved. + {{#if footerText}}{{footerText}}{{else}}© 2026 BentoPDF. All + rights reserved.{{/if}}

Version diff --git a/src/css/styles.css b/src/css/styles.css index c073822..ed4111b 100644 --- a/src/css/styles.css +++ b/src/css/styles.css @@ -76,6 +76,106 @@ body { border-color: #4f46e5; } +.category-header { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + padding: 0.5rem 0; + margin-top: 2rem; + margin-bottom: 1rem; + background: none; + border: none; + cursor: pointer; + font-size: 1.25rem; + font-weight: 700; + color: #ffffff; + text-align: left; +} + +.category-group:first-child .category-header { + margin-top: 0; +} + +.category-header:hover { + color: #818cf8; +} + +.category-header:hover .category-chevron { + color: #818cf8; +} + +.category-tools { + overflow: hidden; + transition: max-height 300ms ease-in-out; +} + +.category-group.collapsed .category-chevron { + transform: rotate(-90deg); +} + +.compact-mode .category-tools { + grid-template-columns: 1fr !important; + gap: 0 !important; +} + +.compact-mode .tool-card { + flex-direction: row; + padding: 0.75rem 1rem; + border-radius: 0; + border-left: none; + border-right: none; + border-top: none; + border-bottom: 1px solid #1f2937; + background-color: transparent; + align-items: center; + justify-content: flex-start; + text-align: left; +} + +.compact-mode .tool-card i { + width: 1.25rem; + height: 1.25rem; + min-width: 1.25rem; + margin-bottom: 0; + margin-right: 0.75rem; + font-size: 1.25rem; +} + +.compact-mode .tool-card i.ph { + font-size: 1.25rem; + margin-bottom: 0; + margin-right: 0.75rem; +} + +.compact-mode .tool-card h3 { + font-size: 0.875rem; + font-weight: 500; +} + +.compact-mode .tool-card p { + display: none; +} + +.compact-mode .tool-card:hover { + transform: none; + box-shadow: none; + background-color: #1f2937; + border-color: #1f2937; +} + +@media (min-width: 640px) { + .compact-mode .category-tools { + grid-template-columns: repeat(2, 1fr) !important; + } +} + +@media (min-width: 1024px) { + .compact-mode .category-tools { + grid-template-columns: repeat(3, 1fr) !important; + } +} + .btn { transition: background-color 0.2s ease-in-out, @@ -116,7 +216,7 @@ input[type='file']::file-selector-button { border: 2px dashed #4f46e5; } -#embed-pdf-container>div { +#embed-pdf-container > div { width: 100%; height: 100%; } @@ -125,8 +225,8 @@ input[type='file']::file-selector-button { color: #39a0ed; } -.page-thumbnail, -#file-list>li { +#page-organizer .page-thumbnail, +#file-list > li { cursor: grab; } @@ -138,19 +238,6 @@ input[type='file']::file-selector-button { position: relative; width: 100%; height: 75vh; - overflow: auto; - border: 2px solid #374151; - border-radius: 0.5rem; - background-color: #1f2937; -} - -/* This rule now ONLY applies to canvases in overlay mode */ -.compare-viewer-wrapper.overlay-mode canvas { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: auto; } .compare-viewer-wrapper.side-by-side-mode { @@ -264,8 +351,6 @@ footer a { z-index: -1; } - - .section-divider { height: 1px; background: linear-gradient(to right, transparent, #4d44f7, transparent); @@ -501,19 +586,19 @@ footer a { color: rgb(165 180 252); } -details>summary { +details > summary { list-style: none; } -details>summary::-webkit-details-marker { +details > summary::-webkit-details-marker { display: none; } -details>summary .icon { +details > summary .icon { transition: transform 0.2s ease-in-out; } -details[open]>summary .icon { +details[open] > summary .icon { transform: rotate(45deg); } @@ -537,7 +622,9 @@ button:disabled, height: 1.5rem; padding: 0 0.25rem; font-size: 0.75rem; - font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + font-family: + ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', + 'Courier New', monospace; line-height: 1; color: #e5e7eb; background-color: #374151; @@ -547,7 +634,9 @@ button:disabled, } .shortcut-input { - font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + font-family: + ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', + 'Courier New', monospace; letter-spacing: 0.05em; } @@ -591,18 +680,49 @@ button:disabled, linear-gradient(to right, rgba(255, 255, 255, 0.05) 1px, transparent 1px), linear-gradient(to bottom, rgba(255, 255, 255, 0.05) 1px, transparent 1px); mask-image: radial-gradient(ellipse at center, black 40%, transparent 80%); - -webkit-mask-image: radial-gradient(ellipse at center, black 40%, transparent 80%); + -webkit-mask-image: radial-gradient( + ellipse at center, + black 40%, + transparent 80% + ); pointer-events: none; } +/* Color picker */ +input[type='color'] { + width: 2.5rem; + height: 2.5rem; + padding: 0; + border: 2px solid #4b5563; + border-radius: 9999px; + background: transparent; + cursor: pointer; + appearance: none; + -webkit-appearance: none; +} + +input[type='color']::-webkit-color-swatch-wrapper { + padding: 0; +} + +input[type='color']::-webkit-color-swatch { + border: none; + border-radius: 9999px; +} + +input[type='color']::-moz-color-swatch { + border: none; + border-radius: 9999px; +} + /* Hide spin buttons for number inputs */ -input[type="number"] { +input[type='number'] { appearance: none; -moz-appearance: textfield; /* Firefox */ } -input[type="number"]::-webkit-inner-spin-button, -input[type="number"]::-webkit-outer-spin-button { +input[type='number']::-webkit-inner-spin-button, +input[type='number']::-webkit-outer-spin-button { -webkit-appearance: none; /* Chrome, Safari, Edge */ margin: 0; -} \ No newline at end of file +} diff --git a/src/js/compare/config.ts b/src/js/compare/config.ts new file mode 100644 index 0000000..42f6653 --- /dev/null +++ b/src/js/compare/config.ts @@ -0,0 +1,39 @@ +export const COMPARE_COLORS = { + added: { r: 34, g: 197, b: 94 }, + removed: { r: 239, g: 68, b: 68 }, + modified: { r: 245, g: 158, b: 11 }, + moved: { r: 168, g: 85, b: 247 }, + 'style-changed': { r: 59, g: 130, b: 246 }, +} as const; + +export const HIGHLIGHT_OPACITY = 0.28; + +export const COMPARE_GEOMETRY = { + LINE_TOLERANCE_FACTOR: 0.6, + MIN_LINE_TOLERANCE: 4, + FOCUS_REGION_PADDING: 40, + FOCUS_REGION_MIN_WIDTH: 320, + FOCUS_REGION_MIN_HEIGHT: 200, +} as const; + +export const COMPARE_RENDER = { + OFFLINE_SCALE: 1.2, + MAX_SCALE_OVERLAY: 2.5, + MAX_SCALE_SIDE: 2.0, + EXPORT_EXTRACT_SCALE: 1.0, + SPLIT_GAP_PT: 2, +} as const; + +export const COMPARE_TEXT = { + DEFAULT_CHAR_WIDTH: 1, + DEFAULT_SPACE_WIDTH: 0.33, +} as const; + +export const VISUAL_DIFF = { + PIXELMATCH_THRESHOLD: 0.12, + ALPHA: 0.2, + DIFF_COLOR: [239, 68, 68] as readonly [number, number, number], + DIFF_COLOR_ALT: [34, 197, 94] as readonly [number, number, number], +} as const; + +export const COMPARE_CACHE_MAX_SIZE = 50; diff --git a/src/js/compare/engine/compare-content.ts b/src/js/compare/engine/compare-content.ts new file mode 100644 index 0000000..64d9209 --- /dev/null +++ b/src/js/compare/engine/compare-content.ts @@ -0,0 +1,239 @@ +import type { + CompareAnnotation, + CompareContentCategory, + CompareImageRef, + ComparePageModel, + CompareRectangle, + CompareTextChange, +} from '../types.ts'; + +const HEADER_FOOTER_ZONE = 0.12; + +export function classifyChangeCategory( + change: CompareTextChange, + pageHeight: number +): CompareContentCategory { + if (change.type === 'style-changed') return 'formatting'; + + const rects = + change.beforeRects.length > 0 ? change.beforeRects : change.afterRects; + if (rects.length > 0 && isHeaderFooterZone(rects, pageHeight)) { + return 'header-footer'; + } + + return 'text'; +} + +function isHeaderFooterZone( + rects: CompareRectangle[], + pageHeight: number +): boolean { + const headerThreshold = pageHeight * HEADER_FOOTER_ZONE; + const footerThreshold = pageHeight * (1 - HEADER_FOOTER_ZONE); + return rects.every( + (r) => r.y < headerThreshold || r.y + r.height > footerThreshold + ); +} + +export function diffAnnotations( + before: CompareAnnotation[], + after: CompareAnnotation[], + baseId: number +): CompareTextChange[] { + const changes: CompareTextChange[] = []; + const beforeMap = new Map( + before + .filter(shouldCompareAnnotation) + .map((annotation) => [annotationKey(annotation), annotation]) + ); + const afterMap = new Map( + after + .filter(shouldCompareAnnotation) + .map((annotation) => [annotationKey(annotation), annotation]) + ); + + let idx = baseId; + for (const [key, ann] of beforeMap) { + if (!afterMap.has(key)) { + changes.push({ + id: `annotation-removed-${idx++}`, + type: 'removed', + category: 'annotation', + description: formatAnnotationDescription('Removed', ann), + beforeText: ann.contents || ann.title || '', + afterText: '', + beforeRects: [ann.rect], + afterRects: [], + }); + } + } + + for (const [key, ann] of afterMap) { + if (!beforeMap.has(key)) { + changes.push({ + id: `annotation-added-${idx++}`, + type: 'added', + category: 'annotation', + description: formatAnnotationDescription('Added', ann), + beforeText: '', + afterText: ann.contents || ann.title || '', + beforeRects: [], + afterRects: [ann.rect], + }); + } + } + + return changes; +} + +function shouldCompareAnnotation(annotation: CompareAnnotation): boolean { + if (annotation.subtype !== 'Highlight') { + return true; + } + + return Boolean(annotation.contents || annotation.title); +} + +function formatAnnotationDescription( + action: 'Added' | 'Removed', + annotation: CompareAnnotation +): string { + const label = annotation.contents || annotation.title; + if (!label) { + return `${action} ${annotation.subtype} annotation`; + } + + return `${action} ${annotation.subtype} annotation: "${label}"`; +} + +function annotationKey(ann: CompareAnnotation): string { + return `${ann.subtype}|${ann.contents}|${Math.round(ann.rect.x)},${Math.round(ann.rect.y)}`; +} + +export function diffImages( + before: CompareImageRef[], + after: CompareImageRef[], + baseId: number +): CompareTextChange[] { + const changes: CompareTextChange[] = []; + const matched = new Set(); + let idx = baseId; + + for (const bImg of before) { + const match = after.find( + (aImg) => !matched.has(aImg.id) && imagesOverlap(bImg.rect, aImg.rect) + ); + if (match) { + matched.add(match.id); + if (bImg.width !== match.width || bImg.height !== match.height) { + changes.push({ + id: `image-modified-${idx++}`, + type: 'modified', + category: 'image', + description: `Image resized from ${bImg.width}×${bImg.height} to ${match.width}×${match.height}`, + beforeText: `${bImg.width}×${bImg.height}`, + afterText: `${match.width}×${match.height}`, + beforeRects: [bImg.rect], + afterRects: [match.rect], + }); + } + } else { + changes.push({ + id: `image-removed-${idx++}`, + type: 'removed', + category: 'image', + description: `Removed image (${bImg.width}×${bImg.height})`, + beforeText: '', + afterText: '', + beforeRects: [bImg.rect], + afterRects: [], + }); + } + } + + for (const aImg of after) { + if (!matched.has(aImg.id)) { + changes.push({ + id: `image-added-${idx++}`, + type: 'added', + category: 'image', + description: `Added image (${aImg.width}×${aImg.height})`, + beforeText: '', + afterText: '', + beforeRects: [], + afterRects: [aImg.rect], + }); + } + } + + return changes; +} + +function imagesOverlap(a: CompareRectangle, b: CompareRectangle): boolean { + const overlapX = Math.max( + 0, + Math.min(a.x + a.width, b.x + b.width) - Math.max(a.x, b.x) + ); + const overlapY = Math.max( + 0, + Math.min(a.y + a.height, b.y + b.height) - Math.max(a.y, b.y) + ); + const overlapArea = overlapX * overlapY; + const aArea = a.width * a.height; + const bArea = b.width * b.height; + const smallerArea = Math.min(aArea, bArea); + return smallerArea > 0 && overlapArea / smallerArea > 0.3; +} + +export function detectBackgroundChanges( + leftModel: ComparePageModel, + rightModel: ComparePageModel, + visualMismatchRatio: number, + textChangeRects: CompareRectangle[], + baseId: number +): CompareTextChange[] { + if (visualMismatchRatio < 0.01) return []; + + const textCoverage = textChangeRects.reduce( + (sum, r) => sum + r.width * r.height, + 0 + ); + const pageArea = leftModel.width * leftModel.height; + const textRatio = pageArea > 0 ? textCoverage / pageArea : 0; + + if (visualMismatchRatio > textRatio + 0.05) { + return [ + { + id: `background-changed-${baseId}`, + type: 'modified', + category: 'background', + description: 'Page background or layout changed', + beforeText: '', + afterText: '', + beforeRects: [ + { x: 0, y: 0, width: leftModel.width, height: leftModel.height }, + ], + afterRects: [ + { x: 0, y: 0, width: rightModel.width, height: rightModel.height }, + ], + }, + ]; + } + + return []; +} + +export function buildCategorySummary(changes: CompareTextChange[]) { + const summary = { + text: 0, + image: 0, + 'header-footer': 0, + annotation: 0, + formatting: 0, + background: 0, + }; + for (const c of changes) { + summary[c.category] += 1; + } + return summary; +} diff --git a/src/js/compare/engine/compare-page-models.ts b/src/js/compare/engine/compare-page-models.ts new file mode 100644 index 0000000..7564ec2 --- /dev/null +++ b/src/js/compare/engine/compare-page-models.ts @@ -0,0 +1,154 @@ +import type { + ComparePageModel, + ComparePageResult, + CompareCategorySummary, +} from '../types.ts'; +import { diffTextRuns } from './diff-text-runs.ts'; +import { diffTextRunsAsync } from '../worker-api.ts'; +import { + classifyChangeCategory, + diffAnnotations, + diffImages, + buildCategorySummary, +} from './compare-content.ts'; + +const EMPTY_CATEGORY_SUMMARY: CompareCategorySummary = { + text: 0, + image: 0, + 'header-footer': 0, + annotation: 0, + formatting: 0, + background: 0, +}; + +export function comparePageModels( + leftPage: ComparePageModel | null, + rightPage: ComparePageModel | null +): ComparePageResult { + return comparePageModelsCore(leftPage, rightPage, false) as ComparePageResult; +} + +export function comparePageModelsAsync( + leftPage: ComparePageModel | null, + rightPage: ComparePageModel | null +): Promise { + return comparePageModelsCore( + leftPage, + rightPage, + true + ) as Promise; +} + +function comparePageModelsCore( + leftPage: ComparePageModel | null, + rightPage: ComparePageModel | null, + useWorker: boolean +): ComparePageResult | Promise { + if (leftPage && !rightPage) { + return { + status: 'left-only', + leftPageNumber: leftPage.pageNumber, + rightPageNumber: null, + changes: [ + { + id: 'page-removed', + type: 'page-removed', + category: 'text', + description: `Page ${leftPage.pageNumber} exists only in the first PDF.`, + beforeText: leftPage.plainText.slice(0, 200), + afterText: '', + beforeRects: [], + afterRects: [], + }, + ], + summary: { added: 0, removed: 1, modified: 0, moved: 0, styleChanged: 0 }, + categorySummary: { ...EMPTY_CATEGORY_SUMMARY, text: 1 }, + visualDiff: null, + usedOcr: leftPage.source === 'ocr', + }; + } + + if (!leftPage && rightPage) { + return { + status: 'right-only', + leftPageNumber: null, + rightPageNumber: rightPage.pageNumber, + changes: [ + { + id: 'page-added', + type: 'page-added', + category: 'text', + description: `Page ${rightPage.pageNumber} exists only in the second PDF.`, + beforeText: '', + afterText: rightPage.plainText.slice(0, 200), + beforeRects: [], + afterRects: [], + }, + ], + summary: { added: 1, removed: 0, modified: 0, moved: 0, styleChanged: 0 }, + categorySummary: { ...EMPTY_CATEGORY_SUMMARY, text: 1 }, + visualDiff: null, + usedOcr: rightPage.source === 'ocr', + }; + } + + if (!leftPage || !rightPage) { + return { + status: 'match', + leftPageNumber: null, + rightPageNumber: null, + changes: [], + summary: { added: 0, removed: 0, modified: 0, moved: 0, styleChanged: 0 }, + categorySummary: { ...EMPTY_CATEGORY_SUMMARY }, + visualDiff: null, + usedOcr: false, + }; + } + + function buildResult(diff: { + changes: ComparePageResult['changes']; + summary: ComparePageResult['summary']; + }): ComparePageResult { + const allChanges = [...diff.changes]; + const pageHeight = Math.max(leftPage!.height, rightPage!.height); + + for (const c of allChanges) { + if (c.category === 'text') { + c.category = classifyChangeCategory(c, pageHeight); + } + } + + const annotChanges = diffAnnotations( + leftPage!.annotations ?? [], + rightPage!.annotations ?? [], + allChanges.length + ); + allChanges.push(...annotChanges); + + const imageChanges = diffImages( + leftPage!.images ?? [], + rightPage!.images ?? [], + allChanges.length + ); + allChanges.push(...imageChanges); + + return { + status: allChanges.length > 0 ? 'changed' : 'match', + leftPageNumber: leftPage!.pageNumber, + rightPageNumber: rightPage!.pageNumber, + changes: allChanges, + summary: diff.summary, + categorySummary: buildCategorySummary(allChanges), + visualDiff: null, + usedOcr: leftPage!.source === 'ocr' || rightPage!.source === 'ocr', + }; + } + + if (useWorker) { + return diffTextRunsAsync(leftPage.textItems, rightPage.textItems).then( + buildResult + ); + } + + return buildResult(diffTextRuns(leftPage.textItems, rightPage.textItems)); +} diff --git a/src/js/compare/engine/compare.worker.ts b/src/js/compare/engine/compare.worker.ts new file mode 100644 index 0000000..21bfc56 --- /dev/null +++ b/src/js/compare/engine/compare.worker.ts @@ -0,0 +1,77 @@ +import { diffTextRuns } from './diff-text-runs.ts'; +import { pairPages } from './pair-pages.ts'; +import type { + CompareTextItem, + ComparePageSignature, + ComparePagePair, + ComparePageResult, + CompareChangeSummary, + CompareTextChange, +} from '../types.ts'; + +interface DiffMessage { + type: 'diff'; + id: number; + beforeItems: CompareTextItem[]; + afterItems: CompareTextItem[]; +} + +interface PairMessage { + type: 'pair'; + id: number; + leftPages: ComparePageSignature[]; + rightPages: ComparePageSignature[]; +} + +type WorkerMessage = DiffMessage | PairMessage; + +interface DiffResult { + type: 'diff'; + id: number; + changes: CompareTextChange[]; + summary: CompareChangeSummary; +} + +interface PairResult { + type: 'pair'; + id: number; + pairs: ComparePagePair[]; +} + +interface ErrorResult { + type: 'error'; + id: number; + message: string; +} + +type WorkerResult = DiffResult | PairResult | ErrorResult; + +self.onmessage = function (e: MessageEvent) { + const msg = e.data; + try { + if (msg.type === 'diff') { + const { changes, summary } = diffTextRuns( + msg.beforeItems, + msg.afterItems + ); + const result: DiffResult = { + type: 'diff', + id: msg.id, + changes, + summary, + }; + self.postMessage(result); + } else if (msg.type === 'pair') { + const pairs = pairPages(msg.leftPages, msg.rightPages); + const result: PairResult = { type: 'pair', id: msg.id, pairs }; + self.postMessage(result); + } + } catch (err) { + const result: ErrorResult = { + type: 'error', + id: msg.id, + message: err instanceof Error ? err.message : String(err), + }; + self.postMessage(result); + } +}; diff --git a/src/js/compare/engine/diff-text-runs.ts b/src/js/compare/engine/diff-text-runs.ts new file mode 100644 index 0000000..ac70ccc --- /dev/null +++ b/src/js/compare/engine/diff-text-runs.ts @@ -0,0 +1,474 @@ +import { diffArrays } from 'diff'; + +import type { + CharPosition, + CompareChangeSummary, + CompareRectangle, + CompareTextChange, + CompareTextItem, + CompareWordToken, +} from '../types.ts'; +import { + calculateBoundingRect, + containsCJK, + segmentCJKText, +} from './text-normalization.ts'; +import { COMPARE_GEOMETRY } from '../config.ts'; + +interface WordToken { + word: string; + compareWord: string; + rect: CompareRectangle; + fontName?: string; + fontSize?: number; +} + +function getCharMap(line: CompareTextItem): CharPosition[] { + if (line.charMap && line.charMap.length === line.normalizedText.length) { + return line.charMap; + } + const charWidth = line.rect.width / Math.max(line.normalizedText.length, 1); + return Array.from({ length: line.normalizedText.length }, (_, i) => ({ + x: line.rect.x + i * charWidth, + width: charWidth, + })); +} + +function splitLineIntoWords(line: CompareTextItem): WordToken[] { + if (line.wordTokens && line.wordTokens.length > 0) { + const baseTokens = line.wordTokens.map((token: CompareWordToken) => ({ + word: token.word, + compareWord: token.compareWord, + rect: token.rect, + fontName: token.fontName, + fontSize: token.fontSize, + })); + if (!containsCJK(line.normalizedText)) return baseTokens; + return baseTokens.flatMap(splitCJKToken); + } + + const words = line.normalizedText.split(/\s+/).filter(Boolean); + if (words.length === 0) return []; + + const charMap = getCharMap(line); + let offset = 0; + + const baseTokens = words.map((word) => { + const startIndex = line.normalizedText.indexOf(word, offset); + const endIndex = startIndex + word.length - 1; + offset = startIndex + word.length; + + const startChar = charMap[startIndex]; + const endChar = charMap[endIndex]; + + if (!startChar || !endChar) { + const charWidth = + line.rect.width / Math.max(line.normalizedText.length, 1); + return { + word, + compareWord: word.toLowerCase(), + rect: { + x: line.rect.x + startIndex * charWidth, + y: line.rect.y, + width: word.length * charWidth, + height: line.rect.height, + }, + }; + } + + const x = startChar.x; + const w = endChar.x + endChar.width - startChar.x; + + return { + word, + compareWord: word.toLowerCase(), + rect: { x, y: line.rect.y, width: w, height: line.rect.height }, + }; + }); + + if (!containsCJK(line.normalizedText)) return baseTokens; + return baseTokens.flatMap(splitCJKToken); +} + +function splitCJKToken(token: WordToken): WordToken[] { + if (!containsCJK(token.word)) return [token]; + + const segments = segmentCJKText(token.word); + if (segments.length <= 1) return [token]; + + const totalLen = token.word.length; + const charWidth = token.rect.width / Math.max(totalLen, 1); + let charOffset = 0; + + return segments.map((seg) => { + const x = token.rect.x + charOffset * charWidth; + const width = seg.length * charWidth; + charOffset += seg.length; + return { + word: seg, + compareWord: seg.toLowerCase(), + rect: { x, y: token.rect.y, width, height: token.rect.height }, + }; + }); +} + +function groupAdjacentRects(rects: CompareRectangle[]): CompareRectangle[] { + if (rects.length === 0) return []; + + const sorted = [...rects].sort((a, b) => a.y - b.y || a.x - b.x); + const groups: CompareRectangle[][] = [[sorted[0]]]; + + for (let i = 1; i < sorted.length; i++) { + const prev = groups[groups.length - 1]; + const lastRect = prev[prev.length - 1]; + const curr = sorted[i]; + const sameLine = + Math.abs(curr.y - lastRect.y) < + Math.max( + lastRect.height * COMPARE_GEOMETRY.LINE_TOLERANCE_FACTOR, + COMPARE_GEOMETRY.MIN_LINE_TOLERANCE + ); + const close = curr.x <= lastRect.x + lastRect.width + lastRect.height * 2; + + if (sameLine && close) { + prev.push(curr); + } else { + groups.push([curr]); + } + } + + return groups.map((group) => calculateBoundingRect(group)); +} + +function collapseWords(words: WordToken[]) { + return words.map((word) => word.compareWord).join(''); +} + +function areEquivalentIgnoringWordBreaks( + beforeWords: WordToken[], + afterWords: WordToken[] +) { + if (beforeWords.length === 0 || afterWords.length === 0) { + return false; + } + + return collapseWords(beforeWords) === collapseWords(afterWords); +} + +function createWordChange( + changes: CompareTextChange[], + type: CompareTextChange['type'], + beforeWords: WordToken[], + afterWords: WordToken[] +) { + const beforeText = beforeWords.map((w) => w.word).join(' '); + const afterText = afterWords.map((w) => w.word).join(' '); + if (!beforeText && !afterText) return; + + const id = `${type}-${changes.length}`; + const beforeRects = groupAdjacentRects(beforeWords.map((w) => w.rect)); + const afterRects = groupAdjacentRects(afterWords.map((w) => w.rect)); + + if (type === 'modified') { + changes.push({ + id, + type, + category: 'text', + description: `Replaced "${beforeText}" with "${afterText}"`, + beforeText, + afterText, + beforeRects, + afterRects, + }); + } else if (type === 'removed') { + changes.push({ + id, + type, + category: 'text', + description: `Removed "${beforeText}"`, + beforeText, + afterText: '', + beforeRects, + afterRects: [], + }); + } else { + changes.push({ + id, + type, + category: 'text', + description: `Added "${afterText}"`, + beforeText: '', + afterText, + beforeRects: [], + afterRects, + }); + } +} + +function toSummary(changes: CompareTextChange[]): CompareChangeSummary { + return changes.reduce( + (summary, change) => { + if (change.type === 'added') summary.added += 1; + if (change.type === 'removed') summary.removed += 1; + if (change.type === 'modified') summary.modified += 1; + if (change.type === 'moved') summary.moved += 1; + if (change.type === 'style-changed') summary.styleChanged += 1; + return summary; + }, + { added: 0, removed: 0, modified: 0, moved: 0, styleChanged: 0 } + ); +} + +export function diffTextRuns( + beforeItems: CompareTextItem[], + afterItems: CompareTextItem[] +) { + const beforeWords = beforeItems.flatMap(splitLineIntoWords); + const afterWords = afterItems.flatMap(splitLineIntoWords); + + const rawChanges = diffArrays( + beforeWords.map((w) => w.compareWord), + afterWords.map((w) => w.compareWord) + ); + + const changes: CompareTextChange[] = []; + let beforeIndex = 0; + let afterIndex = 0; + + for (let i = 0; i < rawChanges.length; i++) { + const change = rawChanges[i]; + const count = change.value.length; + + if (change.removed) { + const removedTokens = beforeWords.slice(beforeIndex, beforeIndex + count); + beforeIndex += count; + + const next = rawChanges[i + 1]; + if (next?.added) { + const addedTokens = afterWords.slice( + afterIndex, + afterIndex + next.value.length + ); + afterIndex += next.value.length; + if (areEquivalentIgnoringWordBreaks(removedTokens, addedTokens)) { + i++; + continue; + } + createWordChange(changes, 'modified', removedTokens, addedTokens); + i++; + } else { + createWordChange(changes, 'removed', removedTokens, []); + } + continue; + } + + if (change.added) { + const addedTokens = afterWords.slice(afterIndex, afterIndex + count); + afterIndex += count; + createWordChange(changes, 'added', [], addedTokens); + continue; + } + + beforeIndex += count; + afterIndex += count; + } + + detectStyleChanges(changes, beforeWords, afterWords, rawChanges); + detectMovedText(changes); + + return { changes, summary: toSummary(changes) }; +} + +function normalizeFontName(name: string): string { + return name.replace(/^g_d\d+_/, 'g_d_'); +} + +function hasStyleDifference(before: WordToken, after: WordToken): boolean { + if ( + before.fontName && + after.fontName && + normalizeFontName(before.fontName) !== normalizeFontName(after.fontName) + ) + return true; + if ( + before.fontSize && + after.fontSize && + Math.abs(before.fontSize - after.fontSize) > 0.5 + ) + return true; + return false; +} + +function detectStyleChanges( + changes: CompareTextChange[], + beforeWords: WordToken[], + afterWords: WordToken[], + rawChanges: ReturnType> +) { + interface StyleFragment { + bFont: string; + aFont: string; + bSize: number | undefined; + aSize: number | undefined; + text: string; + beforeRects: CompareRectangle[]; + afterRects: CompareRectangle[]; + } + + const fragments: StyleFragment[] = []; + let beforeIdx = 0; + let afterIdx = 0; + + for (const change of rawChanges) { + const count = change.value.length; + if (change.removed) { + beforeIdx += count; + continue; + } + if (change.added) { + afterIdx += count; + continue; + } + + let styleRunStart = -1; + for (let k = 0; k < count; k++) { + const bw = beforeWords[beforeIdx + k]; + const aw = afterWords[afterIdx + k]; + const isDiff = hasStyleDifference(bw, aw); + + if (isDiff && styleRunStart < 0) { + styleRunStart = k; + } + if ((!isDiff || k === count - 1) && styleRunStart >= 0) { + const end = isDiff ? k + 1 : k; + const bTokens = beforeWords.slice( + beforeIdx + styleRunStart, + beforeIdx + end + ); + const aTokens = afterWords.slice( + afterIdx + styleRunStart, + afterIdx + end + ); + fragments.push({ + bFont: bTokens[0].fontName ?? 'unknown', + aFont: aTokens[0].fontName ?? 'unknown', + bSize: bTokens[0].fontSize, + aSize: aTokens[0].fontSize, + text: bTokens.map((w) => w.word).join(' '), + beforeRects: groupAdjacentRects(bTokens.map((w) => w.rect)), + afterRects: groupAdjacentRects(aTokens.map((w) => w.rect)), + }); + styleRunStart = -1; + } + } + + beforeIdx += count; + afterIdx += count; + } + + const groups = new Map(); + for (const frag of fragments) { + const key = `${frag.bFont}→${frag.aFont}|${frag.bSize ?? ''}→${frag.aSize ?? ''}`; + const arr = groups.get(key); + if (arr) arr.push(frag); + else groups.set(key, [frag]); + } + + for (const groupFrags of groups.values()) { + const bFont = groupFrags[0].bFont; + const aFont = groupFrags[0].aFont; + const bSize = groupFrags[0].bSize; + const aSize = groupFrags[0].aSize; + const allText = groupFrags.map((f) => f.text).join(' … '); + const allBeforeRects = groupFrags.flatMap((f) => f.beforeRects); + const allAfterRects = groupFrags.flatMap((f) => f.afterRects); + + let desc = `Style changed (${groupFrags.length} regions)`; + const details: string[] = []; + if (bFont !== aFont) details.push(`Font: ${bFont} → ${aFont}`); + if (bSize && aSize && Math.abs(bSize - aSize) > 0.5) + details.push(`Font size: ${bSize} → ${aSize}`); + if (details.length) desc += '\n' + details.map((d) => `• ${d}`).join('\n'); + + changes.push({ + id: `style-changed-${changes.length}`, + type: 'style-changed', + category: 'formatting', + description: desc, + beforeText: allText, + afterText: allText, + beforeRects: allBeforeRects, + afterRects: allAfterRects, + }); + } +} + +const MOVE_MIN_WORDS = 3; +const MOVE_SIMILARITY_THRESHOLD = 0.8; + +function normalizeForMove(text: string): string { + return text.toLowerCase().replace(/\s+/g, ' ').trim(); +} + +function moveSimilarity(a: string, b: string): number { + if (a === b) return 1; + if (!a || !b) return 0; + const aWords = a.split(' '); + const bWords = b.split(' '); + const bSet = new Set(bWords); + let matches = 0; + for (const w of aWords) { + if (bSet.has(w)) matches++; + } + return matches / Math.max(aWords.length, bWords.length); +} + +function detectMovedText(changes: CompareTextChange[]) { + const removed = changes.filter((c) => c.type === 'removed'); + const added = changes.filter((c) => c.type === 'added'); + if (removed.length === 0 || added.length === 0) return; + + const matchedRemoved = new Set(); + const matchedAdded = new Set(); + + for (const rem of removed) { + const remNorm = normalizeForMove(rem.beforeText); + const remWordCount = remNorm.split(' ').length; + if (remWordCount < MOVE_MIN_WORDS) continue; + + let bestMatch: CompareTextChange | null = null; + let bestScore = MOVE_SIMILARITY_THRESHOLD; + + for (const add of added) { + if (matchedAdded.has(add.id)) continue; + const addNorm = normalizeForMove(add.afterText); + const score = moveSimilarity(remNorm, addNorm); + if (score > bestScore) { + bestScore = score; + bestMatch = add; + } + } + + if (bestMatch) { + matchedRemoved.add(rem.id); + matchedAdded.add(bestMatch.id); + + changes.push({ + id: `moved-${changes.length}`, + type: 'moved', + category: 'text', + description: `Moved "${rem.beforeText.slice(0, 80)}"`, + beforeText: rem.beforeText, + afterText: bestMatch.afterText, + beforeRects: rem.beforeRects, + afterRects: bestMatch.afterRects, + }); + } + } + + for (let i = changes.length - 1; i >= 0; i--) { + if (matchedRemoved.has(changes[i].id) || matchedAdded.has(changes[i].id)) { + changes.splice(i, 1); + } + } +} diff --git a/src/js/compare/engine/extract-page-model.ts b/src/js/compare/engine/extract-page-model.ts new file mode 100644 index 0000000..434e463 --- /dev/null +++ b/src/js/compare/engine/extract-page-model.ts @@ -0,0 +1,684 @@ +import * as pdfjsLib from 'pdfjs-dist'; + +import type { + CompareAnnotation, + CompareImageRef, + ComparePageModel, + CompareTextItem, + CharPosition, + CompareWordToken, +} from '../types.ts'; +import { + joinCompareTextItems, + normalizeCompareText, + containsCJK, + segmentCJKText, +} from './text-normalization.ts'; + +type PageTextItem = { + str: string; + width: number; + height: number; + transform: number[]; + dir: string; + fontName: string; + hasEOL: boolean; +}; + +type TextStyles = Record; + +const measurementCanvas = + typeof document !== 'undefined' ? document.createElement('canvas') : null; +const measurementContext = measurementCanvas + ? measurementCanvas.getContext('2d') + : null; +const textMeasurementCache: Map | null = measurementContext + ? new Map() + : null; +let lastMeasurementFont = ''; + +import { COMPARE_TEXT, COMPARE_GEOMETRY } from '../config.ts'; + +const DEFAULT_CHAR_WIDTH = COMPARE_TEXT.DEFAULT_CHAR_WIDTH; +const DEFAULT_SPACE_WIDTH = COMPARE_TEXT.DEFAULT_SPACE_WIDTH; + +function shouldJoinTokenWithPrevious(previous: string, current: string) { + if (!previous) return false; + if (/^[,.;:!?%)\]}]/.test(current)) return true; + if (/^[''"'’”]/u.test(current)) return true; + if (/[([{/"'“‘-]$/u.test(previous)) return true; + return false; +} + +function measureTextWidth(fontSpec: string, text: string): number { + if (!measurementContext) { + if (!text) return 0; + if (text === ' ') return DEFAULT_SPACE_WIDTH; + return text.length * DEFAULT_CHAR_WIDTH; + } + + if (lastMeasurementFont !== fontSpec) { + measurementContext.font = fontSpec; + lastMeasurementFont = fontSpec; + } + + const key = `${fontSpec}|${text}`; + const cached = textMeasurementCache?.get(key); + if (cached !== undefined) { + return cached; + } + + const width = measurementContext.measureText(text).width || 0; + textMeasurementCache?.set(key, width); + return width; +} + +type FontNameMap = Map; + +function buildItemWordTokens( + viewport: pdfjsLib.PageViewport, + item: PageTextItem, + fallbackRect: CompareTextItem['rect'], + styles: TextStyles, + fontNameMap: FontNameMap +): CompareWordToken[] { + const rawText = item.str || ''; + if (!rawText.trim()) { + return []; + } + + const totalLen = Math.max(rawText.length, 1); + const textStyle = item.fontName ? styles[item.fontName] : undefined; + const fontFamily = textStyle?.fontFamily ?? 'sans-serif'; + const fontScale = Math.max( + 0.5, + Math.hypot(item.transform[0], item.transform[1]) || 0 + ); + const fontSpec = `${fontScale}px ${fontFamily}`; + + const weights: number[] = new Array(totalLen); + let runningText = ''; + let previousAdvance = 0; + for (let index = 0; index < totalLen; index += 1) { + runningText += rawText[index]; + const advance = measureTextWidth(fontSpec, runningText); + let width = advance - previousAdvance; + if (!Number.isFinite(width) || width <= 0) { + width = rawText[index] === ' ' ? DEFAULT_SPACE_WIDTH : DEFAULT_CHAR_WIDTH; + } + weights[index] = width; + previousAdvance = advance; + } + + if (!Number.isFinite(previousAdvance) || previousAdvance <= 0) { + for (let index = 0; index < totalLen; index += 1) { + weights[index] = + rawText[index] === ' ' ? DEFAULT_SPACE_WIDTH : DEFAULT_CHAR_WIDTH; + } + } + + const prefix: number[] = new Array(totalLen + 1); + prefix[0] = 0; + for (let index = 0; index < totalLen; index += 1) { + prefix[index + 1] = prefix[index] + weights[index]; + } + const totalWeight = prefix[totalLen] || 1; + + const rawX = item.transform[4]; + const rawY = item.transform[5]; + const transformed = [ + viewport.convertToViewportPoint(rawX, rawY), + viewport.convertToViewportPoint(rawX + item.width, rawY), + viewport.convertToViewportPoint(rawX, rawY + item.height), + viewport.convertToViewportPoint(rawX + item.width, rawY + item.height), + ]; + const xs = transformed.map(([x]) => x); + const ys = transformed.map(([, y]) => y); + const left = Math.min(...xs); + const right = Math.max(...xs); + const top = Math.min(...ys); + const bottom = Math.max(...ys); + + const [baselineStart, baselineEnd, verticalEnd] = transformed; + const baselineVector: [number, number] = [ + baselineEnd[0] - baselineStart[0], + baselineEnd[1] - baselineStart[1], + ]; + const verticalVector: [number, number] = [ + verticalEnd[0] - baselineStart[0], + verticalEnd[1] - baselineStart[1], + ]; + const hasOrientationVectors = + Math.hypot(baselineVector[0], baselineVector[1]) > 1e-6 && + Math.hypot(verticalVector[0], verticalVector[1]) > 1e-6; + + const tokens: CompareWordToken[] = []; + const wordRegex = /\S+/gu; + let match: RegExpExecArray | null; + let previousEnd = 0; + + while ((match = wordRegex.exec(rawText)) !== null) { + const tokenText = match[0]; + const normalizedWord = normalizeCompareText(tokenText); + if (!normalizedWord) { + previousEnd = match.index + tokenText.length; + continue; + } + + const startIndex = match.index; + const endIndex = startIndex + tokenText.length; + const relStart = prefix[startIndex] / totalWeight; + const relEnd = prefix[endIndex] / totalWeight; + + let wordLeft: number; + let wordRight: number; + let wordTop: number; + let wordBottom: number; + + if (hasOrientationVectors) { + const segStart: [number, number] = [ + baselineStart[0] + baselineVector[0] * relStart, + baselineStart[1] + baselineVector[1] * relStart, + ]; + const segEnd: [number, number] = [ + baselineStart[0] + baselineVector[0] * relEnd, + baselineStart[1] + baselineVector[1] * relEnd, + ]; + const cornerPoints: Array<[number, number]> = [ + segStart, + [segStart[0] + verticalVector[0], segStart[1] + verticalVector[1]], + [segEnd[0] + verticalVector[0], segEnd[1] + verticalVector[1]], + segEnd, + ]; + wordLeft = Math.min(...cornerPoints.map(([x]) => x)); + wordRight = Math.max(...cornerPoints.map(([x]) => x)); + wordTop = Math.min(...cornerPoints.map(([, y]) => y)); + wordBottom = Math.max(...cornerPoints.map(([, y]) => y)); + } else { + const segLeft = left + (right - left) * relStart; + const segRight = left + (right - left) * relEnd; + wordLeft = Math.min(segLeft, segRight); + wordRight = Math.max(segLeft, segRight); + wordTop = top; + wordBottom = bottom; + } + + const width = Math.max(wordRight - wordLeft, 1); + const height = Math.max(wordBottom - wordTop, fallbackRect.height); + const gapText = rawText.slice(previousEnd, startIndex); + + const previousToken = tokens[tokens.length - 1]; + + tokens.push({ + word: normalizedWord, + compareWord: normalizedWord.toLowerCase(), + rect: { + x: Number.isFinite(wordLeft) ? wordLeft : fallbackRect.x, + y: Number.isFinite(wordTop) ? wordTop : fallbackRect.y, + width, + height, + }, + joinsWithPrevious: + (gapText.length > 0 && !/\s/u.test(gapText)) || + (previousToken + ? shouldJoinTokenWithPrevious(previousToken.word, normalizedWord) + : false), + fontName: fontNameMap.get(item.fontName) ?? item.fontName ?? undefined, + fontSize: fontScale > 0 ? Math.round(fontScale * 100) / 100 : undefined, + }); + + previousEnd = endIndex; + } + + if (!containsCJK(rawText)) return tokens; + return tokens.flatMap(splitCJKWordToken); +} + +function splitCJKWordToken(token: CompareWordToken): CompareWordToken[] { + if (!containsCJK(token.word)) return [token]; + const segments = segmentCJKText(token.word); + if (segments.length <= 1) return [token]; + + const totalLen = token.word.length; + const charWidth = token.rect.width / Math.max(totalLen, 1); + let charOffset = 0; + + return segments.map((seg, i) => { + const x = token.rect.x + charOffset * charWidth; + const width = seg.length * charWidth; + charOffset += seg.length; + return { + word: seg, + compareWord: seg.toLowerCase(), + rect: { x, y: token.rect.y, width, height: token.rect.height }, + joinsWithPrevious: i > 0 ? true : token.joinsWithPrevious, + fontName: token.fontName, + fontSize: token.fontSize, + }; + }); +} + +function toRect( + viewport: pdfjsLib.PageViewport, + item: PageTextItem, + index: number, + styles: TextStyles, + fontNameMap: FontNameMap +) { + const normalizedText = normalizeCompareText(item.str); + + const transformed = pdfjsLib.Util.transform( + viewport.transform, + item.transform + ); + const width = Math.max(item.width * viewport.scale, 1); + const height = Math.max( + Math.abs(transformed[3]) || item.height * viewport.scale, + 1 + ); + const x = transformed[4]; + const y = transformed[5] - height; + + const rect = { + x, + y, + width, + height, + }; + + return { + id: `${index}-${normalizedText}`, + text: item.str, + normalizedText, + rect, + wordTokens: buildItemWordTokens(viewport, item, rect, styles, fontNameMap), + } satisfies CompareTextItem; +} + +export function sortCompareTextItems(items: CompareTextItem[]) { + return [...items].sort((left, right) => { + const lineTolerance = Math.max( + Math.min(left.rect.height, right.rect.height) * + COMPARE_GEOMETRY.LINE_TOLERANCE_FACTOR, + COMPARE_GEOMETRY.MIN_LINE_TOLERANCE + ); + const topDiff = left.rect.y - right.rect.y; + + if (Math.abs(topDiff) > lineTolerance) { + return topDiff; + } + + const xDiff = left.rect.x - right.rect.x; + if (Math.abs(xDiff) > 1) { + return xDiff; + } + + return left.id.localeCompare(right.id); + }); +} + +function averageCharacterWidth(item: CompareTextItem) { + const compactText = item.normalizedText.replace(/\s+/g, ''); + return item.rect.width / Math.max(compactText.length, 1); +} + +function shouldInsertSpaceBetweenItems( + left: CompareTextItem, + right: CompareTextItem +) { + if (!left.normalizedText || !right.normalizedText) { + return false; + } + + if (/^[,.;:!?%)\]}]/.test(right.normalizedText)) { + return false; + } + + if (/^[''"'’”]/u.test(right.normalizedText)) { + return false; + } + + if (/[([{/"'“‘-]$/u.test(left.normalizedText)) { + return false; + } + + const gap = right.rect.x - (left.rect.x + left.rect.width); + if (gap <= 0) { + return false; + } + + const leftWidth = averageCharacterWidth(left); + const rightWidth = averageCharacterWidth(right); + const threshold = Math.max(Math.min(leftWidth, rightWidth) * 0.45, 1.5); + + return gap >= threshold; +} + +function mergeLineText(lineItems: CompareTextItem[]): { + text: string; + charMap: CharPosition[]; +} { + if (lineItems.length === 0) { + return { text: '', charMap: [] }; + } + + const charMap: CharPosition[] = []; + + function pushFragChars(frag: CompareTextItem) { + const fragText = frag.normalizedText; + const fragCharWidth = frag.rect.width / Math.max(fragText.length, 1); + for (let ci = 0; ci < fragText.length; ci++) { + charMap.push({ + x: frag.rect.x + ci * fragCharWidth, + width: fragCharWidth, + }); + } + } + + let merged = lineItems[0].normalizedText; + pushFragChars(lineItems[0]); + + for (let index = 1; index < lineItems.length; index += 1) { + const previous = lineItems[index - 1]; + const current = lineItems[index]; + + if (shouldInsertSpaceBetweenItems(previous, current)) { + const gap = current.rect.x - (previous.rect.x + previous.rect.width); + charMap.push({ + x: previous.rect.x + previous.rect.width, + width: Math.max(gap, 1), + }); + merged += ` ${current.normalizedText}`; + } else { + merged += current.normalizedText; + } + pushFragChars(current); + } + + return { text: normalizeCompareText(merged), charMap }; +} + +function mergeWordTokenRects( + left: CompareWordToken, + right: CompareWordToken +): CompareWordToken { + const minX = Math.min(left.rect.x, right.rect.x); + const minY = Math.min(left.rect.y, right.rect.y); + const maxX = Math.max( + left.rect.x + left.rect.width, + right.rect.x + right.rect.width + ); + const maxY = Math.max( + left.rect.y + left.rect.height, + right.rect.y + right.rect.height + ); + + return { + word: `${left.word}${right.word}`, + compareWord: `${left.compareWord}${right.compareWord}`, + rect: { + x: minX, + y: minY, + width: maxX - minX, + height: maxY - minY, + }, + fontName: left.fontName, + fontSize: left.fontSize, + }; +} + +function buildMergedWordTokens(lineItems: CompareTextItem[]) { + if ( + !lineItems.some((item) => item.wordTokens && item.wordTokens.length > 0) + ) { + return undefined; + } + + const mergedTokens: CompareWordToken[] = []; + let previousItem: CompareTextItem | null = null; + + for (const item of lineItems) { + const itemTokens = + item.wordTokens && item.wordTokens.length > 0 + ? item.wordTokens + : [ + { + word: item.normalizedText, + compareWord: item.normalizedText.toLowerCase(), + rect: item.rect, + } satisfies CompareWordToken, + ]; + + itemTokens.forEach((token, tokenIndex) => { + const joinsAcrossItems = + tokenIndex === 0 && previousItem + ? !shouldInsertSpaceBetweenItems(previousItem, item) + : false; + const shouldJoin = + mergedTokens.length > 0 && + (tokenIndex > 0 ? Boolean(token.joinsWithPrevious) : joinsAcrossItems); + + if (shouldJoin) { + mergedTokens[mergedTokens.length - 1] = mergeWordTokenRects( + mergedTokens[mergedTokens.length - 1], + token + ); + } else { + mergedTokens.push({ + word: token.word, + compareWord: token.compareWord, + rect: token.rect, + fontName: token.fontName, + fontSize: token.fontSize, + }); + } + }); + + previousItem = item; + } + + return mergedTokens; +} + +export function mergeIntoLines( + sortedItems: CompareTextItem[] +): CompareTextItem[] { + if (sortedItems.length === 0) return []; + + const lines: CompareTextItem[][] = []; + let currentLine: CompareTextItem[] = [sortedItems[0]]; + + for (let i = 1; i < sortedItems.length; i++) { + const anchor = currentLine[0]; + const curr = sortedItems[i]; + const lineTolerance = Math.max( + Math.min(anchor.rect.height, curr.rect.height) * + COMPARE_GEOMETRY.LINE_TOLERANCE_FACTOR, + COMPARE_GEOMETRY.MIN_LINE_TOLERANCE + ); + + if (Math.abs(curr.rect.y - anchor.rect.y) <= lineTolerance) { + currentLine.push(curr); + } else { + lines.push(currentLine); + currentLine = [curr]; + } + } + lines.push(currentLine); + + return lines.map((lineItems, lineIndex) => { + const { text: normalizedText, charMap } = mergeLineText(lineItems); + + const minX = Math.min(...lineItems.map((item) => item.rect.x)); + const minY = Math.min(...lineItems.map((item) => item.rect.y)); + const maxX = Math.max( + ...lineItems.map((item) => item.rect.x + item.rect.width) + ); + const maxY = Math.max( + ...lineItems.map((item) => item.rect.y + item.rect.height) + ); + + return { + id: `line-${lineIndex}`, + text: lineItems.map((item) => item.text).join(' '), + normalizedText, + rect: { + x: minX, + y: minY, + width: maxX - minX, + height: maxY - minY, + }, + fragments: lineItems, + charMap, + wordTokens: buildMergedWordTokens(lineItems), + }; + }); +} + +function extractAnnotations( + rawAnnotations: Array>, + viewport: pdfjsLib.PageViewport +): CompareAnnotation[] { + return rawAnnotations + .filter((ann) => { + const subtype = ann.subtype as string | undefined; + return subtype && subtype !== 'Link' && subtype !== 'Widget'; + }) + .map((ann, index) => { + const rawRect = ann.rect as number[] | undefined; + let rect = { x: 0, y: 0, width: 0, height: 0 }; + if (rawRect && rawRect.length === 4) { + const [p1, p2] = [ + viewport.convertToViewportPoint(rawRect[0], rawRect[1]), + viewport.convertToViewportPoint(rawRect[2], rawRect[3]), + ]; + const x = Math.min(p1[0], p2[0]); + const y = Math.min(p1[1], p2[1]); + rect = { + x, + y, + width: Math.max(Math.abs(p2[0] - p1[0]), 1), + height: Math.max(Math.abs(p2[1] - p1[1]), 1), + }; + } + const color = ann.color as number[] | undefined; + return { + id: `ann-${index}`, + subtype: (ann.subtype as string) || 'Unknown', + rect, + contents: ((ann.contents as string) || '').trim(), + title: ((ann.title as string) || '').trim(), + color: color ? `rgb(${color.join(',')})` : '', + }; + }); +} + +function extractImages( + opList: { fnArray: number[]; argsArray: unknown[][] }, + viewport: pdfjsLib.PageViewport +): CompareImageRef[] { + const OPS_PAINT_IMAGE = 85; + const OPS_PAINT_INLINE_IMAGE = 84; + const images: CompareImageRef[] = []; + + for (let i = 0; i < opList.fnArray.length; i++) { + const op = opList.fnArray[i]; + if (op !== OPS_PAINT_IMAGE && op !== OPS_PAINT_INLINE_IMAGE) continue; + + const args = opList.argsArray[i]; + if (!args) continue; + + let imgWidth = 0; + let imgHeight = 0; + + if (op === OPS_PAINT_INLINE_IMAGE && args[0]) { + const imgData = args[0] as Record; + imgWidth = (imgData.width as number) || 0; + imgHeight = (imgData.height as number) || 0; + } else if (op === OPS_PAINT_IMAGE) { + imgWidth = (args[1] as number) || 0; + imgHeight = (args[2] as number) || 0; + } + + if (imgWidth < 2 || imgHeight < 2) continue; + + const [vpX, vpY] = viewport.convertToViewportPoint(0, 0); + const [vpX2, vpY2] = viewport.convertToViewportPoint(imgWidth, imgHeight); + const x = Math.min(vpX, vpX2); + const y = Math.min(vpY, vpY2); + + images.push({ + id: `img-${images.length}`, + rect: { + x, + y, + width: Math.abs(vpX2 - vpX) || imgWidth, + height: Math.abs(vpY2 - vpY) || imgHeight, + }, + width: imgWidth, + height: imgHeight, + }); + } + + return images; +} + +export async function extractPageModel( + page: pdfjsLib.PDFPageProxy, + viewport: pdfjsLib.PageViewport +): Promise { + const [textContent, rawAnnotations, opList] = await Promise.all([ + page.getTextContent(), + page + .getAnnotations({ intent: 'any' }) + .catch(() => [] as Array>), + page + .getOperatorList() + .catch(() => ({ fnArray: [] as number[], argsArray: [] as unknown[][] })), + ]); + const styles = textContent.styles ?? {}; + + const fontNameMap: FontNameMap = new Map(); + const seenFonts = new Set(); + for (const item of textContent.items) { + if ('fontName' in item && typeof item.fontName === 'string') { + seenFonts.add(item.fontName); + } + } + for (const internalName of seenFonts) { + try { + if (page.commonObjs.has(internalName)) { + const fontObj = page.commonObjs.get(internalName); + if (fontObj?.name && typeof fontObj.name === 'string') { + fontNameMap.set(internalName, fontObj.name); + } + } + } catch {} + } + + const rawItems = sortCompareTextItems( + textContent.items + .filter((item): item is PageTextItem => 'str' in item) + .map((item, index) => toRect(viewport, item, index, styles, fontNameMap)) + .filter((item) => item.normalizedText.length > 0) + ); + const textItems = mergeIntoLines(rawItems); + + return { + pageNumber: page.pageNumber, + width: viewport.width, + height: viewport.height, + textItems, + plainText: joinCompareTextItems(textItems), + hasText: textItems.length > 0, + source: 'pdfjs', + annotations: extractAnnotations( + rawAnnotations as Array>, + viewport + ), + images: extractImages( + opList as { fnArray: number[]; argsArray: unknown[][] }, + viewport + ), + }; +} diff --git a/src/js/compare/engine/ocr-page.ts b/src/js/compare/engine/ocr-page.ts new file mode 100644 index 0000000..40abf00 --- /dev/null +++ b/src/js/compare/engine/ocr-page.ts @@ -0,0 +1,78 @@ +import type Tesseract from 'tesseract.js'; +import type { ComparePageModel, CompareTextItem } from '../types.ts'; +import { mergeIntoLines, sortCompareTextItems } from './extract-page-model.ts'; +import { + joinCompareTextItems, + normalizeCompareText, +} from './text-normalization.ts'; +import { createConfiguredTesseractWorker } from '../../utils/tesseract-runtime.js'; + +type OcrWord = Tesseract.Word; +type OcrRecognizeResult = Tesseract.RecognizeResult; +type OcrPageWithWords = Tesseract.Page & { words: OcrWord[] }; + +export async function recognizePageCanvas( + canvas: HTMLCanvasElement, + language: string, + onProgress?: (status: string, progress: number) => void +): Promise { + const worker = await createConfiguredTesseractWorker( + language, + 1, + (message) => { + onProgress?.(message.status, message.progress || 0); + } + ); + + let result: OcrRecognizeResult; + try { + result = await worker.recognize(canvas); + } finally { + await worker.terminate(); + } + + const words = (result.data as OcrPageWithWords).words + .map((word, index) => { + const normalizedText = normalizeCompareText(word.text); + if (!normalizedText) return null; + + const item: CompareTextItem = { + id: `ocr-${index}-${normalizedText}`, + text: word.text, + normalizedText, + rect: { + x: word.bbox.x0, + y: word.bbox.y0, + width: Math.max(word.bbox.x1 - word.bbox.x0, 1), + height: Math.max(word.bbox.y1 - word.bbox.y0, 1), + }, + wordTokens: [ + { + word: normalizedText, + compareWord: normalizedText.toLowerCase(), + rect: { + x: word.bbox.x0, + y: word.bbox.y0, + width: Math.max(word.bbox.x1 - word.bbox.x0, 1), + height: Math.max(word.bbox.y1 - word.bbox.y0, 1), + }, + }, + ], + }; + + return item; + }) + .filter((word): word is CompareTextItem => Boolean(word)); + + const mergedItems = mergeIntoLines(sortCompareTextItems(words)); + + return { + pageNumber: 0, + width: canvas.width, + height: canvas.height, + textItems: mergedItems, + plainText: joinCompareTextItems(mergedItems), + hasText: mergedItems.length > 0, + source: 'ocr', + }; +} diff --git a/src/js/compare/engine/page-signatures.ts b/src/js/compare/engine/page-signatures.ts new file mode 100644 index 0000000..81cc4c4 --- /dev/null +++ b/src/js/compare/engine/page-signatures.ts @@ -0,0 +1,61 @@ +import * as pdfjsLib from 'pdfjs-dist'; + +import type { ComparePageSignature, CompareTextItem } from '../types.ts'; +import { + joinNormalizedText, + normalizeCompareText, +} from './text-normalization.ts'; + +type SignatureTextItem = { + str: string; + dir: string; + transform: number[]; + width: number; + height: number; + fontName: string; + hasEOL: boolean; +}; + +function tokenToItem(token: string, index: number): CompareTextItem { + return { + id: `token-${index}-${token}`, + text: token, + normalizedText: token, + rect: { x: 0, y: 0, width: 0, height: 0 }, + }; +} + +export async function extractPageSignature( + pdfDoc: pdfjsLib.PDFDocumentProxy, + pageNumber: number +): Promise { + const page = await pdfDoc.getPage(pageNumber); + const textContent = await page.getTextContent(); + const tokens = textContent.items + .filter((item): item is SignatureTextItem => 'str' in item) + .map((item) => normalizeCompareText(item.str)) + .filter(Boolean); + + const limitedTokens = tokens.slice(0, 500); + + return { + pageNumber, + plainText: joinNormalizedText(limitedTokens), + hasText: limitedTokens.length > 0, + tokenItems: limitedTokens.map((token, index) => tokenToItem(token, index)), + }; +} + +export async function extractDocumentSignatures( + pdfDoc: pdfjsLib.PDFDocumentProxy, + onProgress?: (pageNumber: number, totalPages: number) => void +) { + const signatures: ComparePageSignature[] = []; + + for (let pageNumber = 1; pageNumber <= pdfDoc.numPages; pageNumber += 1) { + onProgress?.(pageNumber, pdfDoc.numPages); + signatures.push(await extractPageSignature(pdfDoc, pageNumber)); + } + + return signatures; +} diff --git a/src/js/compare/engine/pair-pages.ts b/src/js/compare/engine/pair-pages.ts new file mode 100644 index 0000000..12da83e --- /dev/null +++ b/src/js/compare/engine/pair-pages.ts @@ -0,0 +1,119 @@ +import type { ComparePagePair, ComparePageSignature } from '../types.ts'; +import { tokenizeTextAsSet } from './text-normalization.ts'; + +function similarityScore( + left: ComparePageSignature, + right: ComparePageSignature +) { + if (!left.hasText && !right.hasText) { + return left.pageNumber === right.pageNumber ? 0.7 : 0.35; + } + + if (!left.hasText || !right.hasText) { + return 0.08; + } + + const leftTokens = tokenizeTextAsSet(left.plainText); + const rightTokens = tokenizeTextAsSet(right.plainText); + const union = new Set([...leftTokens, ...rightTokens]); + let intersectionCount = 0; + + leftTokens.forEach((token) => { + if (rightTokens.has(token)) intersectionCount += 1; + }); + + const jaccard = union.size === 0 ? 0 : intersectionCount / union.size; + const positionalBias = left.pageNumber === right.pageNumber ? 0.1 : 0; + return Math.min(jaccard + positionalBias, 1); +} + +export function pairPages( + leftPages: ComparePageSignature[], + rightPages: ComparePageSignature[] +) { + const insertionCost = 0.8; + const rowCount = leftPages.length + 1; + const colCount = rightPages.length + 1; + const dp = Array.from({ length: rowCount }, () => + Array(colCount).fill(0) + ); + const backtrack = Array.from({ length: rowCount }, () => + Array<'match' | 'left' | 'right'>(colCount).fill('match') + ); + + for (let i = 1; i < rowCount; i += 1) { + dp[i][0] = i * insertionCost; + backtrack[i][0] = 'left'; + } + + for (let j = 1; j < colCount; j += 1) { + dp[0][j] = j * insertionCost; + backtrack[0][j] = 'right'; + } + + for (let i = 1; i < rowCount; i += 1) { + for (let j = 1; j < colCount; j += 1) { + const similarity = similarityScore(leftPages[i - 1], rightPages[j - 1]); + const matchCost = dp[i - 1][j - 1] + (1 - similarity); + const leftCost = dp[i - 1][j] + insertionCost; + const rightCost = dp[i][j - 1] + insertionCost; + + const minCost = Math.min(matchCost, leftCost, rightCost); + dp[i][j] = minCost; + + if (minCost === matchCost) { + backtrack[i][j] = 'match'; + } else if (minCost === leftCost) { + backtrack[i][j] = 'left'; + } else { + backtrack[i][j] = 'right'; + } + } + } + + const pairs: ComparePagePair[] = []; + let i = leftPages.length; + let j = rightPages.length; + + while (i > 0 || j > 0) { + const direction = backtrack[i][j]; + + if (i > 0 && j > 0 && direction === 'match') { + const confidence = similarityScore(leftPages[i - 1], rightPages[j - 1]); + pairs.push({ + pairIndex: 0, + leftPageNumber: leftPages[i - 1].pageNumber, + rightPageNumber: rightPages[j - 1].pageNumber, + confidence, + }); + i -= 1; + j -= 1; + continue; + } + + if (i > 0 && (j === 0 || direction === 'left')) { + pairs.push({ + pairIndex: 0, + leftPageNumber: leftPages[i - 1].pageNumber, + rightPageNumber: null, + confidence: 0, + }); + i -= 1; + continue; + } + + if (j > 0) { + pairs.push({ + pairIndex: 0, + leftPageNumber: null, + rightPageNumber: rightPages[j - 1].pageNumber, + confidence: 0, + }); + j -= 1; + } + } + + return pairs + .reverse() + .map((pair, index) => ({ ...pair, pairIndex: index + 1 })); +} diff --git a/src/js/compare/engine/text-normalization.ts b/src/js/compare/engine/text-normalization.ts new file mode 100644 index 0000000..896b0c8 --- /dev/null +++ b/src/js/compare/engine/text-normalization.ts @@ -0,0 +1,109 @@ +import type { CompareRectangle, CompareTextItem } from '../types.ts'; + +export function normalizeCompareText(text: string) { + return text + .normalize('NFKC') + .replace(/[\u0000-\u001F\u007F-\u009F]/g, ' ') + .replace(/[\u{E000}-\u{F8FF}]/gu, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +function shouldAppendWithoutSpace(current: string, next: string) { + if (!current) return true; + if (/^[,.;:!?%)\]}]/.test(next)) return true; + if (/^["']$/.test(next)) return true; + if (/^['’”]/u.test(next)) return true; + if (/[([{/"'“‘-]$/u.test(current)) return true; + return false; +} + +export function joinNormalizedText(tokens: string[]) { + return tokens.reduce((result, token) => { + if (!token) return result; + if (shouldAppendWithoutSpace(result, token)) { + return `${result}${token}`; + } + return `${result} ${token}`; + }, ''); +} + +export function joinCompareTextItems(items: CompareTextItem[]) { + return joinNormalizedText(items.map((item) => item.normalizedText)); +} + +export function isLowQualityExtractedText(text: string) { + const normalized = normalizeCompareText(text); + if (!normalized) return true; + + const tokens = normalized.split(/\s+/).filter(Boolean); + const visibleCharacters = Array.from(normalized).filter( + (character) => character.trim().length > 0 + ); + const alphaNumericCount = visibleCharacters.filter((character) => + /[\p{L}\p{N}]/u.test(character) + ).length; + const symbolCount = visibleCharacters.length - alphaNumericCount; + const tokenWithAlphaNumericCount = tokens.filter((token) => + /[\p{L}\p{N}]/u.test(token) + ).length; + + if (alphaNumericCount === 0) return true; + if ( + visibleCharacters.length >= 12 && + alphaNumericCount / visibleCharacters.length < 0.45 && + symbolCount / visibleCharacters.length > 0.35 + ) { + return true; + } + if (tokens.length >= 6 && tokenWithAlphaNumericCount / tokens.length < 0.6) { + return true; + } + + return false; +} + +export function tokenizeText(text: string): string[] { + return text.split(/\s+/).filter(Boolean); +} + +export function tokenizeTextAsSet(text: string): Set { + return new Set(tokenizeText(text)); +} + +const CJK_REGEX = + /[\u2E80-\u9FFF\uF900-\uFAFF\uFE30-\uFE4F\u{20000}-\u{2FA1F}]/u; + +export function containsCJK(text: string): boolean { + return CJK_REGEX.test(text); +} + +let cachedSegmenter: Intl.Segmenter | null = null; + +function getWordSegmenter(): Intl.Segmenter | null { + if (cachedSegmenter) return cachedSegmenter; + if (typeof Intl !== 'undefined' && Intl.Segmenter) { + cachedSegmenter = new Intl.Segmenter(undefined, { granularity: 'word' }); + return cachedSegmenter; + } + return null; +} + +export function segmentCJKText(text: string): string[] { + const segmenter = getWordSegmenter(); + if (!segmenter) return [text]; + return [...segmenter.segment(text)] + .filter((seg) => seg.isWordLike) + .map((seg) => seg.segment); +} + +export function calculateBoundingRect( + rects: CompareRectangle[] +): CompareRectangle { + if (rects.length === 0) return { x: 0, y: 0, width: 0, height: 0 }; + const minX = Math.min(...rects.map((r) => r.x)); + const minY = Math.min(...rects.map((r) => r.y)); + const maxX = Math.max(...rects.map((r) => r.x + r.width)); + const maxY = Math.max(...rects.map((r) => r.y + r.height)); + return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }; +} diff --git a/src/js/compare/engine/visual-diff.ts b/src/js/compare/engine/visual-diff.ts new file mode 100644 index 0000000..0057472 --- /dev/null +++ b/src/js/compare/engine/visual-diff.ts @@ -0,0 +1,139 @@ +import pixelmatch from 'pixelmatch'; + +import type { CompareVisualDiff } from '../types.ts'; +import { VISUAL_DIFF as VISUAL_DIFF_CONFIG } from '../config.ts'; + +type FocusRegion = { + x: number; + y: number; + width: number; + height: number; +}; + +function createCanvas(width: number, height: number) { + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + return canvas; +} + +function drawNormalized( + sourceCanvas: HTMLCanvasElement, + targetCanvas: HTMLCanvasElement +) { + const context = targetCanvas.getContext('2d'); + if (!context) { + throw new Error('Could not create comparison canvas context.'); + } + + context.fillStyle = '#ffffff'; + context.fillRect(0, 0, targetCanvas.width, targetCanvas.height); + + const offsetX = Math.floor((targetCanvas.width - sourceCanvas.width) / 2); + const offsetY = Math.floor((targetCanvas.height - sourceCanvas.height) / 2); + context.drawImage(sourceCanvas, offsetX, offsetY); +} + +export function renderVisualDiff( + canvas1: HTMLCanvasElement, + canvas2: HTMLCanvasElement, + outputCanvas: HTMLCanvasElement, + focusRegion?: FocusRegion +): CompareVisualDiff { + const width = Math.max(canvas1.width, canvas2.width, 1); + const height = Math.max(canvas1.height, canvas2.height, 1); + const normalizedCanvas1 = createCanvas(width, height); + const normalizedCanvas2 = createCanvas(width, height); + + drawNormalized(canvas1, normalizedCanvas1); + drawNormalized(canvas2, normalizedCanvas2); + + outputCanvas.width = width; + outputCanvas.height = height; + + const context1 = normalizedCanvas1.getContext('2d'); + const context2 = normalizedCanvas2.getContext('2d'); + const outputContext = outputCanvas.getContext('2d'); + + if (!context1 || !context2 || !outputContext) { + throw new Error('Could not create visual diff context.'); + } + + const image1 = context1.getImageData(0, 0, width, height); + const image2 = context2.getImageData(0, 0, width, height); + const diffImage = outputContext.createImageData(width, height); + + const mismatchPixels = pixelmatch( + image1.data, + image2.data, + diffImage.data, + width, + height, + { + threshold: VISUAL_DIFF_CONFIG.PIXELMATCH_THRESHOLD, + includeAA: false, + alpha: VISUAL_DIFF_CONFIG.ALPHA, + diffMask: false, + diffColor: [...VISUAL_DIFF_CONFIG.DIFF_COLOR] as [number, number, number], + diffColorAlt: [...VISUAL_DIFF_CONFIG.DIFF_COLOR_ALT] as [ + number, + number, + number, + ], + } + ); + + const overlayCanvas = createCanvas(width, height); + const overlayContext = overlayCanvas.getContext('2d'); + + if (!overlayContext) { + throw new Error('Could not create visual diff overlay context.'); + } + + overlayContext.putImageData(diffImage, 0, 0); + + const region = focusRegion + ? { + x: Math.max(Math.floor(focusRegion.x), 0), + y: Math.max(Math.floor(focusRegion.y), 0), + width: Math.min(Math.ceil(focusRegion.width), width), + height: Math.min(Math.ceil(focusRegion.height), height), + } + : { x: 0, y: 0, width, height }; + + outputCanvas.width = Math.max(region.width, 1); + outputCanvas.height = Math.max(region.height, 1); + + outputContext.fillStyle = '#ffffff'; + outputContext.fillRect(0, 0, outputCanvas.width, outputCanvas.height); + outputContext.drawImage( + normalizedCanvas2, + region.x, + region.y, + region.width, + region.height, + 0, + 0, + outputCanvas.width, + outputCanvas.height + ); + outputContext.globalAlpha = 0.9; + outputContext.drawImage( + overlayCanvas, + region.x, + region.y, + region.width, + region.height, + 0, + 0, + outputCanvas.width, + outputCanvas.height + ); + outputContext.globalAlpha = 1; + + return { + mismatchPixels, + mismatchRatio: mismatchPixels / Math.max(width * height, 1), + hasDiff: mismatchPixels > 0, + }; +} diff --git a/src/js/compare/lru-cache.ts b/src/js/compare/lru-cache.ts new file mode 100644 index 0000000..591f3c1 --- /dev/null +++ b/src/js/compare/lru-cache.ts @@ -0,0 +1,38 @@ +export class LRUCache { + private map = new Map(); + private maxSize: number; + + constructor(maxSize: number) { + this.maxSize = maxSize; + } + + get(key: K): V | undefined { + const value = this.map.get(key); + if (value !== undefined) { + this.map.delete(key); + this.map.set(key, value); + } + return value; + } + + set(key: K, value: V) { + this.map.delete(key); + this.map.set(key, value); + if (this.map.size > this.maxSize) { + const oldest = this.map.keys().next().value; + if (oldest !== undefined) this.map.delete(oldest); + } + } + + has(key: K): boolean { + return this.map.has(key); + } + + clear() { + this.map.clear(); + } + + get size(): number { + return this.map.size; + } +} diff --git a/src/js/compare/reporting/export-compare-pdf.ts b/src/js/compare/reporting/export-compare-pdf.ts new file mode 100644 index 0000000..3e56afc --- /dev/null +++ b/src/js/compare/reporting/export-compare-pdf.ts @@ -0,0 +1,251 @@ +import { PDFDocument, rgb } from 'pdf-lib'; +import * as pdfjsLib from 'pdfjs-dist'; +import type { + ComparePagePair, + CompareTextChange, + ComparePdfExportMode, +} from '../types.ts'; +import { extractPageModel } from '../engine/extract-page-model.ts'; +import { comparePageModelsAsync } from '../engine/compare-page-models.ts'; +import { + COMPARE_COLORS, + HIGHLIGHT_OPACITY, + COMPARE_RENDER, +} from '../config.ts'; +import { downloadFile } from '../../utils/helpers.ts'; + +const HIGHLIGHT_COLORS: Record< + string, + { r: number; g: number; b: number; opacity: number } +> = { + added: { + r: COMPARE_COLORS.added.r / 255, + g: COMPARE_COLORS.added.g / 255, + b: COMPARE_COLORS.added.b / 255, + opacity: HIGHLIGHT_OPACITY, + }, + removed: { + r: COMPARE_COLORS.removed.r / 255, + g: COMPARE_COLORS.removed.g / 255, + b: COMPARE_COLORS.removed.b / 255, + opacity: HIGHLIGHT_OPACITY, + }, + 'page-removed': { + r: COMPARE_COLORS.removed.r / 255, + g: COMPARE_COLORS.removed.g / 255, + b: COMPARE_COLORS.removed.b / 255, + opacity: HIGHLIGHT_OPACITY, + }, + modified: { + r: COMPARE_COLORS.modified.r / 255, + g: COMPARE_COLORS.modified.g / 255, + b: COMPARE_COLORS.modified.b / 255, + opacity: HIGHLIGHT_OPACITY, + }, + moved: { + r: COMPARE_COLORS.moved.r / 255, + g: COMPARE_COLORS.moved.g / 255, + b: COMPARE_COLORS.moved.b / 255, + opacity: HIGHLIGHT_OPACITY, + }, + 'style-changed': { + r: COMPARE_COLORS['style-changed'].r / 255, + g: COMPARE_COLORS['style-changed'].g / 255, + b: COMPARE_COLORS['style-changed'].b / 255, + opacity: HIGHLIGHT_OPACITY, + }, +}; + +const EXTRACT_SCALE = COMPARE_RENDER.EXPORT_EXTRACT_SCALE; + +function drawHighlights( + page: ReturnType, + pageHeight: number, + changes: CompareTextChange[], + side: 'before' | 'after' +) { + for (const change of changes) { + const rects = side === 'before' ? change.beforeRects : change.afterRects; + const color = HIGHLIGHT_COLORS[change.type]; + if (!color) continue; + for (const rect of rects) { + page.drawRectangle({ + x: rect.x / EXTRACT_SCALE, + y: pageHeight - rect.y / EXTRACT_SCALE - rect.height / EXTRACT_SCALE, + width: rect.width / EXTRACT_SCALE, + height: rect.height / EXTRACT_SCALE, + color: rgb(color.r, color.g, color.b), + opacity: color.opacity, + }); + } + } +} + +export async function exportComparePdf( + mode: ComparePdfExportMode, + pdfDoc1: pdfjsLib.PDFDocumentProxy | null, + pdfDoc2: pdfjsLib.PDFDocumentProxy | null, + pairs: ComparePagePair[], + onProgress?: (message: string, percent: number) => void +) { + if (!pdfDoc1 && !pdfDoc2) { + throw new Error('At least one PDF document is required for export.'); + } + if (!pairs || pairs.length === 0) { + throw new Error('No page pairs to export.'); + } + + const outDoc = await PDFDocument.create(); + + const [bytes1, bytes2] = await Promise.all([ + pdfDoc1?.getData(), + pdfDoc2?.getData(), + ]); + + const [libDoc1, libDoc2] = await Promise.all([ + bytes1 ? PDFDocument.load(bytes1, { ignoreEncryption: true }) : null, + bytes2 ? PDFDocument.load(bytes2, { ignoreEncryption: true }) : null, + ]); + + for (let i = 0; i < pairs.length; i++) { + const pair = pairs[i]; + onProgress?.( + `Rendering page ${i + 1} of ${pairs.length}...`, + Math.round(((i + 1) / pairs.length) * 100) + ); + + const leftPdjsPage = + pair.leftPageNumber && pdfDoc1 + ? await pdfDoc1.getPage(pair.leftPageNumber) + : null; + const rightPdjsPage = + pair.rightPageNumber && pdfDoc2 + ? await pdfDoc2.getPage(pair.rightPageNumber) + : null; + + const leftModel = leftPdjsPage + ? await extractPageModel( + leftPdjsPage, + leftPdjsPage.getViewport({ scale: EXTRACT_SCALE }) + ) + : null; + const rightModel = rightPdjsPage + ? await extractPageModel( + rightPdjsPage, + rightPdjsPage.getViewport({ scale: EXTRACT_SCALE }) + ) + : null; + + const comparison = await comparePageModelsAsync(leftModel, rightModel); + const changes = comparison.changes; + + if (mode === 'split') { + const refPage = leftPdjsPage || rightPdjsPage; + const vp = refPage!.getViewport({ scale: 1.0 }); + const gap = COMPARE_RENDER.SPLIT_GAP_PT; + const totalW = vp.width * 2 + gap; + const outPage = outDoc.addPage([totalW, vp.height]); + + if (pair.leftPageNumber && libDoc1) { + const [copied] = await outDoc.copyPages(libDoc1, [ + pair.leftPageNumber - 1, + ]); + const embedded = await outDoc.embedPage(copied); + outPage.drawPage(embedded, { + x: 0, + y: 0, + width: vp.width, + height: vp.height, + }); + } + if (pair.rightPageNumber && libDoc2) { + const [copied] = await outDoc.copyPages(libDoc2, [ + pair.rightPageNumber - 1, + ]); + const embedded = await outDoc.embedPage(copied); + outPage.drawPage(embedded, { + x: vp.width + gap, + y: 0, + width: vp.width, + height: vp.height, + }); + } + + if (changes.length) { + for (const change of changes) { + const color = HIGHLIGHT_COLORS[change.type]; + if (!color) continue; + for (const rect of change.beforeRects) { + outPage.drawRectangle({ + x: rect.x / EXTRACT_SCALE, + y: + vp.height - + rect.y / EXTRACT_SCALE - + rect.height / EXTRACT_SCALE, + width: rect.width / EXTRACT_SCALE, + height: rect.height / EXTRACT_SCALE, + color: rgb(color.r, color.g, color.b), + opacity: color.opacity, + }); + } + for (const rect of change.afterRects) { + outPage.drawRectangle({ + x: vp.width + gap + rect.x / EXTRACT_SCALE, + y: + vp.height - + rect.y / EXTRACT_SCALE - + rect.height / EXTRACT_SCALE, + width: rect.width / EXTRACT_SCALE, + height: rect.height / EXTRACT_SCALE, + color: rgb(color.r, color.g, color.b), + opacity: color.opacity, + }); + } + } + } + } else if (mode === 'alternating') { + if (pair.leftPageNumber && libDoc1) { + const [copied] = await outDoc.copyPages(libDoc1, [ + pair.leftPageNumber - 1, + ]); + const embedded = outDoc.addPage(copied); + const { height } = embedded.getSize(); + if (changes.length) drawHighlights(embedded, height, changes, 'before'); + } + if (pair.rightPageNumber && libDoc2) { + const [copied] = await outDoc.copyPages(libDoc2, [ + pair.rightPageNumber - 1, + ]); + const embedded = outDoc.addPage(copied); + const { height } = embedded.getSize(); + if (changes.length) drawHighlights(embedded, height, changes, 'after'); + } + } else if (mode === 'left') { + if (pair.leftPageNumber && libDoc1) { + const [copied] = await outDoc.copyPages(libDoc1, [ + pair.leftPageNumber - 1, + ]); + const embedded = outDoc.addPage(copied); + const { height } = embedded.getSize(); + if (changes.length) drawHighlights(embedded, height, changes, 'before'); + } + } else { + if (pair.rightPageNumber && libDoc2) { + const [copied] = await outDoc.copyPages(libDoc2, [ + pair.rightPageNumber - 1, + ]); + const embedded = outDoc.addPage(copied); + const { height } = embedded.getSize(); + if (changes.length) drawHighlights(embedded, height, changes, 'after'); + } + } + + await new Promise((r) => setTimeout(r, 0)); + } + + const pdfBytes = await outDoc.save(); + const blob = new Blob([pdfBytes.buffer as ArrayBuffer], { + type: 'application/pdf', + }); + downloadFile(blob, 'bentopdf-compare-export.pdf'); +} diff --git a/src/js/compare/types.ts b/src/js/compare/types.ts new file mode 100644 index 0000000..d5d7a09 --- /dev/null +++ b/src/js/compare/types.ts @@ -0,0 +1,214 @@ +import type * as pdfjsLib from 'pdfjs-dist'; +import type { LRUCache } from './lru-cache.ts'; + +export type CompareViewMode = 'overlay' | 'side-by-side'; + +export type ComparePdfExportMode = 'split' | 'alternating' | 'left' | 'right'; + +export interface RenderedPage { + model: ComparePageModel | null; + exists: boolean; +} + +export interface ComparisonPageLoad { + model: ComparePageModel | null; + exists: boolean; +} + +export interface DiffFocusRegion { + x: number; + y: number; + width: number; + height: number; +} + +export interface OcrCacheEntry { + model: ComparePageModel; + width: number; + height: number; +} + +export interface CompareCaches { + pageModelCache: LRUCache; + comparisonCache: LRUCache; + comparisonResultsCache: LRUCache; + ocrModelCache: LRUCache; +} + +export interface CompareRenderContext { + useOcr: boolean; + ocrLanguage: string; + viewMode: CompareViewMode; + zoomLevel: number; + showLoader: (message: string, percent?: number) => void; +} + +export interface CompareRectangle { + x: number; + y: number; + width: number; + height: number; +} + +export interface CharPosition { + x: number; + width: number; +} + +export interface CompareWordToken { + word: string; + compareWord: string; + rect: CompareRectangle; + joinsWithPrevious?: boolean; + fontName?: string; + fontSize?: number; +} + +export interface CompareTextItem { + id: string; + text: string; + normalizedText: string; + rect: CompareRectangle; + fragments?: CompareTextItem[]; + charMap?: CharPosition[]; + wordTokens?: CompareWordToken[]; +} + +export interface ComparePageModel { + pageNumber: number; + width: number; + height: number; + textItems: CompareTextItem[]; + plainText: string; + hasText: boolean; + source: 'pdfjs' | 'ocr'; + annotations?: CompareAnnotation[]; + images?: CompareImageRef[]; +} + +export interface CompareAnnotation { + id: string; + subtype: string; + rect: CompareRectangle; + contents: string; + title: string; + color: string; +} + +export interface CompareImageRef { + id: string; + rect: CompareRectangle; + width: number; + height: number; +} + +export interface ComparePageSignature { + pageNumber: number; + plainText: string; + hasText: boolean; + tokenItems: CompareTextItem[]; +} + +export interface ComparePagePair { + pairIndex: number; + leftPageNumber: number | null; + rightPageNumber: number | null; + confidence: number; +} + +export interface CompareVisualDiff { + mismatchPixels: number; + mismatchRatio: number; + hasDiff: boolean; +} + +export type CompareChangeType = + | 'added' + | 'removed' + | 'modified' + | 'moved' + | 'style-changed' + | 'page-added' + | 'page-removed'; + +export type CompareContentCategory = + | 'text' + | 'image' + | 'header-footer' + | 'annotation' + | 'formatting' + | 'background'; + +export interface CompareTextChange { + id: string; + type: CompareChangeType; + category: CompareContentCategory; + description: string; + beforeText: string; + afterText: string; + beforeRects: CompareRectangle[]; + afterRects: CompareRectangle[]; +} + +export interface CompareChangeSummary { + added: number; + removed: number; + modified: number; + moved: number; + styleChanged: number; +} + +export interface CompareCategorySummary { + text: number; + image: number; + 'header-footer': number; + annotation: number; + formatting: number; + background: number; +} + +export interface ComparePageResult { + status: 'match' | 'changed' | 'left-only' | 'right-only'; + leftPageNumber: number | null; + rightPageNumber: number | null; + changes: CompareTextChange[]; + summary: CompareChangeSummary; + categorySummary: CompareCategorySummary; + visualDiff: CompareVisualDiff | null; + confidence?: number; + usedOcr?: boolean; +} + +export type CompareFilterType = + | 'added' + | 'removed' + | 'modified' + | 'moved' + | 'style-changed' + | 'all'; + +export interface CompareCategoryFilterState { + text: boolean; + image: boolean; + 'header-footer': boolean; + annotation: boolean; + formatting: boolean; + background: boolean; +} + +export interface CompareState { + pdfDoc1: pdfjsLib.PDFDocumentProxy | null; + pdfDoc2: pdfjsLib.PDFDocumentProxy | null; + currentPage: number; + viewMode: CompareViewMode; + isSyncScroll: boolean; + currentComparison: ComparePageResult | null; + activeChangeIndex: number; + pagePairs: ComparePagePair[]; + activeFilter: CompareFilterType; + categoryFilter: CompareCategoryFilterState; + changeSearchQuery: string; + useOcr: boolean; + ocrLanguage: string; + zoomLevel: number; +} diff --git a/src/js/compare/worker-api.ts b/src/js/compare/worker-api.ts new file mode 100644 index 0000000..919a176 --- /dev/null +++ b/src/js/compare/worker-api.ts @@ -0,0 +1,90 @@ +import type { + CompareTextItem, + ComparePageSignature, + ComparePagePair, + CompareChangeSummary, + CompareTextChange, +} from './types.ts'; +import { diffTextRuns } from './engine/diff-text-runs.ts'; +import { pairPages } from './engine/pair-pages.ts'; + +let worker: Worker | null = null; +let messageId = 0; +const pending = new Map< + number, + { resolve: (value: unknown) => void; reject: (reason: unknown) => void } +>(); + +function getWorker(): Worker | null { + if (worker) return worker; + try { + worker = new Worker( + new URL('./engine/compare.worker.ts', import.meta.url), + { type: 'module' } + ); + worker.onmessage = function (e) { + const { id, type, ...rest } = e.data; + const p = pending.get(id); + if (!p) return; + pending.delete(id); + if (type === 'error') { + p.reject(new Error((rest as { message: string }).message)); + } else { + p.resolve(rest); + } + }; + worker.onerror = function () { + worker?.terminate(); + worker = null; + for (const [, p] of pending) { + p.reject(new Error('Worker crashed')); + } + pending.clear(); + }; + return worker; + } catch { + return null; + } +} + +function postToWorker(msg: Record): Promise { + const w = getWorker(); + if (!w) return Promise.reject(new Error('No worker')); + const id = ++messageId; + return new Promise((resolve, reject) => { + pending.set(id, { resolve, reject }); + w.postMessage({ ...msg, id }); + }); +} + +export async function diffTextRunsAsync( + beforeItems: CompareTextItem[], + afterItems: CompareTextItem[] +): Promise<{ changes: CompareTextChange[]; summary: CompareChangeSummary }> { + try { + const result = (await postToWorker({ + type: 'diff', + beforeItems, + afterItems, + })) as { changes: CompareTextChange[]; summary: CompareChangeSummary }; + return result; + } catch { + return diffTextRuns(beforeItems, afterItems); + } +} + +export async function pairPagesAsync( + leftPages: ComparePageSignature[], + rightPages: ComparePageSignature[] +): Promise { + try { + const result = (await postToWorker({ + type: 'pair', + leftPages, + rightPages, + })) as { pairs: ComparePagePair[] }; + return result.pairs; + } catch { + return pairPages(leftPages, rightPages); + } +} diff --git a/src/js/config/font-mappings.ts b/src/js/config/font-mappings.ts index c6c0c31..6a3df53 100644 --- a/src/js/config/font-mappings.ts +++ b/src/js/config/font-mappings.ts @@ -1,189 +1,233 @@ -/** - * Font mappings for OCR text layer rendering - * Maps Tesseract language codes to appropriate Noto Sans font families and their CDN URLs - */ - -export const languageToFontFamily: Record = { - // CJK Languages - jpn: 'Noto Sans JP', - chi_sim: 'Noto Sans SC', - chi_tra: 'Noto Sans TC', - kor: 'Noto Sans KR', - - // Arabic Script - ara: 'Noto Sans Arabic', - fas: 'Noto Sans Arabic', - urd: 'Noto Sans Arabic', - pus: 'Noto Sans Arabic', - kur: 'Noto Sans Arabic', - - // Devanagari Script - hin: 'Noto Sans Devanagari', - mar: 'Noto Sans Devanagari', - san: 'Noto Sans Devanagari', - nep: 'Noto Sans Devanagari', - - // Bengali Script - ben: 'Noto Sans Bengali', - asm: 'Noto Sans Bengali', - - // Tamil Script - tam: 'Noto Sans Tamil', - - // Telugu Script - tel: 'Noto Sans Telugu', - - // Kannada Script - kan: 'Noto Sans Kannada', - - // Malayalam Script - mal: 'Noto Sans Malayalam', - - // Gujarati Script - guj: 'Noto Sans Gujarati', - - // Gurmukhi Script (Punjabi) - pan: 'Noto Sans Gurmukhi', - - // Oriya Script - ori: 'Noto Sans Oriya', - - // Sinhala Script - sin: 'Noto Sans Sinhala', - - // Thai Script - tha: 'Noto Sans Thai', - - // Lao Script - lao: 'Noto Sans Lao', - - // Khmer Script - khm: 'Noto Sans Khmer', - - // Myanmar Script - mya: 'Noto Sans Myanmar', - - // Tibetan Script - bod: 'Noto Serif Tibetan', - - // Georgian Script - kat: 'Noto Sans Georgian', - kat_old: 'Noto Sans Georgian', - - // Armenian Script - hye: 'Noto Sans Armenian', - - // Hebrew Script - heb: 'Noto Sans Hebrew', - yid: 'Noto Sans Hebrew', - - // Ethiopic Script - amh: 'Noto Sans Ethiopic', - tir: 'Noto Sans Ethiopic', - - // Cherokee Script - chr: 'Noto Sans Cherokee', - - // Syriac Script - syr: 'Noto Sans Syriac', - - // Cyrillic Script (Noto Sans includes Cyrillic) - bel: 'Noto Sans', - bul: 'Noto Sans', - mkd: 'Noto Sans', - rus: 'Noto Sans', - srp: 'Noto Sans', - srp_latn: 'Noto Sans', - ukr: 'Noto Sans', - kaz: 'Noto Sans', - kir: 'Noto Sans', - tgk: 'Noto Sans', - uzb: 'Noto Sans', - uzb_cyrl: 'Noto Sans', - aze_cyrl: 'Noto Sans', - - // Latin Script (covered by base Noto Sans) - afr: 'Noto Sans', - aze: 'Noto Sans', - bos: 'Noto Sans', - cat: 'Noto Sans', - ceb: 'Noto Sans', - ces: 'Noto Sans', - cym: 'Noto Sans', - dan: 'Noto Sans', - deu: 'Noto Sans', - ell: 'Noto Sans', - eng: 'Noto Sans', - enm: 'Noto Sans', - epo: 'Noto Sans', - est: 'Noto Sans', - eus: 'Noto Sans', - fin: 'Noto Sans', - fra: 'Noto Sans', - frk: 'Noto Sans', - frm: 'Noto Sans', - gle: 'Noto Sans', - glg: 'Noto Sans', - grc: 'Noto Sans', - hat: 'Noto Sans', - hrv: 'Noto Sans', - hun: 'Noto Sans', - iku: 'Noto Sans', - ind: 'Noto Sans', - isl: 'Noto Sans', - ita: 'Noto Sans', - ita_old: 'Noto Sans', - jav: 'Noto Sans', - lat: 'Noto Sans', - lav: 'Noto Sans', - lit: 'Noto Sans', - mlt: 'Noto Sans', - msa: 'Noto Sans', - nld: 'Noto Sans', - nor: 'Noto Sans', - pol: 'Noto Sans', - por: 'Noto Sans', - ron: 'Noto Sans', - slk: 'Noto Sans', - slv: 'Noto Sans', - spa: 'Noto Sans', - spa_old: 'Noto Sans', - sqi: 'Noto Sans', - swa: 'Noto Sans', - swe: 'Noto Sans', - tgl: 'Noto Sans', - tur: 'Noto Sans', - vie: 'Noto Sans', - dzo: 'Noto Sans', - uig: 'Noto Sans', -}; - -export const fontFamilyToUrl: Record = { - 'Noto Sans JP': 'https://raw.githack.com/googlefonts/noto-cjk/main/Sans/OTF/Japanese/NotoSansCJKjp-Regular.otf', - 'Noto Sans SC': 'https://raw.githack.com/googlefonts/noto-cjk/main/Sans/OTF/SimplifiedChinese/NotoSansCJKsc-Regular.otf', - 'Noto Sans TC': 'https://raw.githack.com/googlefonts/noto-cjk/main/Sans/OTF/TraditionalChinese/NotoSansCJKtc-Regular.otf', - 'Noto Sans KR': 'https://raw.githack.com/googlefonts/noto-cjk/main/Sans/OTF/Korean/NotoSansCJKkr-Regular.otf', - 'Noto Sans Arabic': 'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansArabic/NotoSansArabic-Regular.ttf', - 'Noto Sans Devanagari': 'https://raw.githack.com/googlefonts/noto-fonts/main/unhinted/ttf/NotoSansDevanagari/NotoSansDevanagari-Regular.ttf', - 'Noto Sans Bengali': 'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansBengali/NotoSansBengali-Regular.ttf', - 'Noto Sans Gujarati': 'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansGujarati/NotoSansGujarati-Regular.ttf', - 'Noto Sans Kannada': 'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansKannada/NotoSansKannada-Regular.ttf', - 'Noto Sans Malayalam': 'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansMalayalam/NotoSansMalayalam-Regular.ttf', - 'Noto Sans Oriya': 'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansOriya/NotoSansOriya-Regular.ttf', - 'Noto Sans Gurmukhi': 'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansGurmukhi/NotoSansGurmukhi-Regular.ttf', - 'Noto Sans Tamil': 'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansTamil/NotoSansTamil-Regular.ttf', - 'Noto Sans Telugu': 'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansTelugu/NotoSansTelugu-Regular.ttf', - 'Noto Sans Sinhala': 'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansSinhala/NotoSansSinhala-Regular.ttf', - 'Noto Sans Thai': 'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansThai/NotoSansThai-Regular.ttf', - 'Noto Sans Khmer': 'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansKhmer/NotoSansKhmer-Regular.ttf', - 'Noto Sans Lao': 'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansLao/NotoSansLao-Regular.ttf', - 'Noto Sans Myanmar': 'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansMyanmar/NotoSansMyanmar-Regular.ttf', - 'Noto Sans Hebrew': 'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansHebrew/NotoSansHebrew-Regular.ttf', - 'Noto Sans Georgian': 'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansGeorgian/NotoSansGeorgian-Regular.ttf', - 'Noto Sans Ethiopic': 'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansEthiopic/NotoSansEthiopic-Regular.ttf', - 'Noto Serif Tibetan': 'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSerifTibetan/NotoSerifTibetan-Regular.ttf', - 'Noto Sans Cherokee': 'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansCherokee/NotoSansCherokee-Regular.ttf', - 'Noto Sans Armenian': 'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansArmenian/NotoSansArmenian-Regular.ttf', - 'Noto Sans Syriac': 'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansSyriac/NotoSansSyriac-Regular.ttf', - 'Noto Sans': 'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSans/NotoSans-Regular.ttf', -}; \ No newline at end of file +/** + * Font mappings for OCR text layer rendering + * Maps Tesseract language codes to appropriate Noto Sans font families and their CDN URLs + */ + +export const languageToFontFamily: Record = { + // CJK Languages + jpn: 'Noto Sans JP', + chi_sim: 'Noto Sans SC', + chi_tra: 'Noto Sans TC', + kor: 'Noto Sans KR', + + // Arabic Script + ara: 'Noto Sans Arabic', + fas: 'Noto Sans Arabic', + urd: 'Noto Sans Arabic', + pus: 'Noto Sans Arabic', + kur: 'Noto Sans Arabic', + + // Devanagari Script + hin: 'Noto Sans Devanagari', + mar: 'Noto Sans Devanagari', + san: 'Noto Sans Devanagari', + nep: 'Noto Sans Devanagari', + + // Bengali Script + ben: 'Noto Sans Bengali', + asm: 'Noto Sans Bengali', + + // Tamil Script + tam: 'Noto Sans Tamil', + + // Telugu Script + tel: 'Noto Sans Telugu', + + // Kannada Script + kan: 'Noto Sans Kannada', + + // Malayalam Script + mal: 'Noto Sans Malayalam', + + // Gujarati Script + guj: 'Noto Sans Gujarati', + + // Gurmukhi Script (Punjabi) + pan: 'Noto Sans Gurmukhi', + + // Oriya Script + ori: 'Noto Sans Oriya', + + // Sinhala Script + sin: 'Noto Sans Sinhala', + + // Thai Script + tha: 'Noto Sans Thai', + + // Lao Script + lao: 'Noto Sans Lao', + + // Khmer Script + khm: 'Noto Sans Khmer', + + // Myanmar Script + mya: 'Noto Sans Myanmar', + + // Tibetan Script + bod: 'Noto Serif Tibetan', + + // Georgian Script + kat: 'Noto Sans Georgian', + kat_old: 'Noto Sans Georgian', + + // Armenian Script + hye: 'Noto Sans Armenian', + + // Hebrew Script + heb: 'Noto Sans Hebrew', + yid: 'Noto Sans Hebrew', + + // Ethiopic Script + amh: 'Noto Sans Ethiopic', + tir: 'Noto Sans Ethiopic', + + // Cherokee Script + chr: 'Noto Sans Cherokee', + + // Syriac Script + syr: 'Noto Sans Syriac', + + // Cyrillic Script (Noto Sans includes Cyrillic) + bel: 'Noto Sans', + bul: 'Noto Sans', + mkd: 'Noto Sans', + rus: 'Noto Sans', + srp: 'Noto Sans', + srp_latn: 'Noto Sans', + ukr: 'Noto Sans', + kaz: 'Noto Sans', + kir: 'Noto Sans', + tgk: 'Noto Sans', + uzb: 'Noto Sans', + uzb_cyrl: 'Noto Sans', + aze_cyrl: 'Noto Sans', + + // Latin Script (covered by base Noto Sans) + afr: 'Noto Sans', + aze: 'Noto Sans', + bos: 'Noto Sans', + cat: 'Noto Sans', + ceb: 'Noto Sans', + ces: 'Noto Sans', + cym: 'Noto Sans', + dan: 'Noto Sans', + deu: 'Noto Sans', + ell: 'Noto Sans', + eng: 'Noto Sans', + enm: 'Noto Sans', + epo: 'Noto Sans', + est: 'Noto Sans', + eus: 'Noto Sans', + fin: 'Noto Sans', + fra: 'Noto Sans', + frk: 'Noto Sans', + frm: 'Noto Sans', + gle: 'Noto Sans', + glg: 'Noto Sans', + grc: 'Noto Sans', + hat: 'Noto Sans', + hrv: 'Noto Sans', + hun: 'Noto Sans', + iku: 'Noto Sans', + ind: 'Noto Sans', + isl: 'Noto Sans', + ita: 'Noto Sans', + ita_old: 'Noto Sans', + jav: 'Noto Sans', + lat: 'Noto Sans', + lav: 'Noto Sans', + lit: 'Noto Sans', + mlt: 'Noto Sans', + msa: 'Noto Sans', + nld: 'Noto Sans', + nor: 'Noto Sans', + pol: 'Noto Sans', + por: 'Noto Sans', + ron: 'Noto Sans', + slk: 'Noto Sans', + slv: 'Noto Sans', + spa: 'Noto Sans', + spa_old: 'Noto Sans', + sqi: 'Noto Sans', + swa: 'Noto Sans', + swe: 'Noto Sans', + tgl: 'Noto Sans', + tur: 'Noto Sans', + vie: 'Noto Sans', + dzo: 'Noto Sans', + uig: 'Noto Sans', +}; + +export const fontFamilyToUrl: Record = { + 'Noto Sans JP': + 'https://raw.githack.com/googlefonts/noto-cjk/main/Sans/OTF/Japanese/NotoSansCJKjp-Regular.otf', + 'Noto Sans SC': + 'https://raw.githack.com/googlefonts/noto-cjk/main/Sans/OTF/SimplifiedChinese/NotoSansCJKsc-Regular.otf', + 'Noto Sans TC': + 'https://raw.githack.com/googlefonts/noto-cjk/main/Sans/OTF/TraditionalChinese/NotoSansCJKtc-Regular.otf', + 'Noto Sans KR': + 'https://raw.githack.com/googlefonts/noto-cjk/main/Sans/OTF/Korean/NotoSansCJKkr-Regular.otf', + 'Noto Sans Arabic': + 'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansArabic/NotoSansArabic-Regular.ttf', + 'Noto Sans Devanagari': + 'https://raw.githack.com/googlefonts/noto-fonts/main/unhinted/ttf/NotoSansDevanagari/NotoSansDevanagari-Regular.ttf', + 'Noto Sans Bengali': + 'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansBengali/NotoSansBengali-Regular.ttf', + 'Noto Sans Gujarati': + 'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansGujarati/NotoSansGujarati-Regular.ttf', + 'Noto Sans Kannada': + 'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansKannada/NotoSansKannada-Regular.ttf', + 'Noto Sans Malayalam': + 'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansMalayalam/NotoSansMalayalam-Regular.ttf', + 'Noto Sans Oriya': + 'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansOriya/NotoSansOriya-Regular.ttf', + 'Noto Sans Gurmukhi': + 'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansGurmukhi/NotoSansGurmukhi-Regular.ttf', + 'Noto Sans Tamil': + 'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansTamil/NotoSansTamil-Regular.ttf', + 'Noto Sans Telugu': + 'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansTelugu/NotoSansTelugu-Regular.ttf', + 'Noto Sans Sinhala': + 'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansSinhala/NotoSansSinhala-Regular.ttf', + 'Noto Sans Thai': + 'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansThai/NotoSansThai-Regular.ttf', + 'Noto Sans Khmer': + 'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansKhmer/NotoSansKhmer-Regular.ttf', + 'Noto Sans Lao': + 'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansLao/NotoSansLao-Regular.ttf', + 'Noto Sans Myanmar': + 'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansMyanmar/NotoSansMyanmar-Regular.ttf', + 'Noto Sans Hebrew': + 'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansHebrew/NotoSansHebrew-Regular.ttf', + 'Noto Sans Georgian': + 'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansGeorgian/NotoSansGeorgian-Regular.ttf', + 'Noto Sans Ethiopic': + 'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansEthiopic/NotoSansEthiopic-Regular.ttf', + 'Noto Serif Tibetan': + 'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSerifTibetan/NotoSerifTibetan-Regular.ttf', + 'Noto Sans Cherokee': + 'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansCherokee/NotoSansCherokee-Regular.ttf', + 'Noto Sans Armenian': + 'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansArmenian/NotoSansArmenian-Regular.ttf', + 'Noto Sans Syriac': + 'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansSyriac/NotoSansSyriac-Regular.ttf', + 'Noto Sans': + 'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSans/NotoSans-Regular.ttf', +}; + +export function getFontUrlForFamily(fontFamily: string): string { + return fontFamilyToUrl[fontFamily] || fontFamilyToUrl['Noto Sans']; +} + +export function getFontAssetFileName(fontFamily: string): string { + const defaultUrl = getFontUrlForFamily(fontFamily); + const fileName = defaultUrl.split('/').pop(); + + if (!fileName) { + throw new Error( + `Could not resolve a font asset filename for ${fontFamily}` + ); + } + + return fileName; +} diff --git a/src/js/config/tools.ts b/src/js/config/tools.ts index 4299090..0772dd5 100644 --- a/src/js/config/tools.ts +++ b/src/js/config/tools.ts @@ -1,8 +1,15 @@ // This file centralizes the definition of all available tools, organized by category. -export const categories = [ +const baseCategories = [ { name: 'Popular Tools', tools: [ + { + href: import.meta.env.BASE_URL + 'pdf-workflow.html', + name: 'PDF Workflow Builder', + icon: 'ph-tree-structure', + subtitle: + 'Build custom PDF processing pipelines with a visual node editor.', + }, { href: import.meta.env.BASE_URL + 'pdf-multi-tool.html', name: 'PDF Multi Tool', @@ -101,6 +108,12 @@ export const categories = [ icon: 'ph-list-numbers', subtitle: 'Insert page numbers into your document.', }, + { + href: import.meta.env.BASE_URL + 'bates-numbering.html', + name: 'Bates Numbering', + icon: 'ph-hash', + subtitle: 'Add sequential Bates numbers across one or more PDF files.', + }, { href: import.meta.env.BASE_URL + 'add-watermark.html', name: 'Add Watermark', @@ -119,6 +132,18 @@ export const categories = [ icon: 'ph-circle-half', subtitle: 'Create a "dark mode" version of your PDF.', }, + { + href: import.meta.env.BASE_URL + 'scanner-effect.html', + name: 'Scanner Effect', + icon: 'ph-scan', + subtitle: 'Make your PDF look like a scanned document.', + }, + { + href: import.meta.env.BASE_URL + 'adjust-colors.html', + name: 'Adjust Colors', + icon: 'ph-sliders-horizontal', + subtitle: 'Fine-tune brightness, contrast, saturation and more.', + }, { href: import.meta.env.BASE_URL + 'background-color.html', name: 'Background Color', @@ -763,3 +788,16 @@ export const categories = [ ], }, ]; + +const getToolIdFromHref = (href: string): string => { + const match = href.match(/\/([^/]+)\.html$/); + return match?.[1] ?? href; +}; + +export const categories = baseCategories.map((category) => ({ + ...category, + tools: category.tools.map((tool) => ({ + ...tool, + id: getToolIdFromHref(tool.href), + })), +})); diff --git a/src/js/config/wasm-cdn-config.ts b/src/js/config/wasm-cdn-config.ts index 94b082d..cf68445 100644 --- a/src/js/config/wasm-cdn-config.ts +++ b/src/js/config/wasm-cdn-config.ts @@ -1,75 +1,52 @@ -/** - * WASM CDN Configuration - * - * Centralized configuration for loading WASM files from jsDelivr CDN or local paths. - * Supports environment-based toggling and automatic fallback mechanism. - */ +import { PACKAGE_VERSIONS } from '../const/cdn-version'; +import { WasmProvider } from '../utils/wasm-provider'; -const USE_CDN = import.meta.env.VITE_USE_CDN === 'true'; -import { CDN_URLS, PACKAGE_VERSIONS } from '../const/cdn-version'; +export type WasmPackage = 'ghostscript' | 'pymupdf' | 'cpdf'; -const LOCAL_PATHS = { - ghostscript: import.meta.env.BASE_URL + 'ghostscript-wasm/', - pymupdf: import.meta.env.BASE_URL + 'pymupdf-wasm/', -} as const; +export function getWasmBaseUrl(packageName: WasmPackage): string | undefined { + const userUrl = WasmProvider.getUrl(packageName); + if (userUrl) { + console.log( + `[WASM Config] Using configured URL for ${packageName}: ${userUrl}` + ); + return userUrl; + } -export type WasmPackage = 'ghostscript' | 'pymupdf'; - -export function getWasmBaseUrl(packageName: WasmPackage): string { - if (USE_CDN) { - return CDN_URLS[packageName]; - } - return LOCAL_PATHS[packageName]; + console.warn( + `[WASM Config] No URL configured for ${packageName}. Feature unavailable.` + ); + return undefined; } -export function getWasmFallbackUrl(packageName: WasmPackage): string { - return LOCAL_PATHS[packageName]; +export function isWasmAvailable(packageName: WasmPackage): boolean { + return WasmProvider.isConfigured(packageName); } - -export function isCdnEnabled(): boolean { - return USE_CDN; -} - -/** - * Fetch a file with automatic CDN → local fallback - * @param packageName - WASM package name - * @param fileName - File name relative to package base - * @returns Response object - */ export async function fetchWasmFile( - packageName: WasmPackage, - fileName: string + packageName: WasmPackage, + fileName: string ): Promise { - const cdnUrl = CDN_URLS[packageName] + fileName; - const localUrl = LOCAL_PATHS[packageName] + fileName; + const baseUrl = getWasmBaseUrl(packageName); - if (USE_CDN) { - try { - console.log(`[WASM CDN] Fetching from CDN: ${cdnUrl}`); - const response = await fetch(cdnUrl); - if (response.ok) { - return response; - } - console.warn(`[WASM CDN] CDN fetch failed with status ${response.status}, trying local fallback...`); - } catch (error) { - console.warn(`[WASM CDN] CDN fetch error:`, error, `- trying local fallback...`); - } - } + if (!baseUrl) { + throw new Error( + `No URL configured for ${packageName}. Please configure it in WASM Settings.` + ); + } - const response = await fetch(localUrl); - if (!response.ok) { - throw new Error(`Failed to fetch ${fileName}: HTTP ${response.status}`); - } - return response; + const url = baseUrl + fileName; + console.log(`[WASM] Fetching: ${url}`); + + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to fetch ${fileName}: HTTP ${response.status}`); + } + return response; } -// use this to debug export function getWasmConfigInfo() { - return { - cdnEnabled: USE_CDN, - packageVersions: PACKAGE_VERSIONS, - cdnUrls: CDN_URLS, - localPaths: LOCAL_PATHS, - }; + return { + packageVersions: PACKAGE_VERSIONS, + configuredProviders: WasmProvider.getAllProviders(), + }; } diff --git a/src/js/const/cdn-version.ts b/src/js/const/cdn-version.ts index c5ec5f4..3b8fe31 100644 --- a/src/js/const/cdn-version.ts +++ b/src/js/const/cdn-version.ts @@ -1,9 +1,4 @@ export const PACKAGE_VERSIONS = { - ghostscript: '0.1.0', - pymupdf: '0.1.9', + ghostscript: '0.1.1', + pymupdf: '0.11.16', } as const; - -export const CDN_URLS = { - ghostscript: `https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm@${PACKAGE_VERSIONS.ghostscript}/assets/`, - pymupdf: `https://cdn.jsdelivr.net/npm/@bentopdf/pymupdf-wasm@${PACKAGE_VERSIONS.pymupdf}/assets/`, -} as const; \ No newline at end of file diff --git a/src/js/i18n/i18n.ts b/src/js/i18n/i18n.ts index 3152fb8..b8eead4 100644 --- a/src/js/i18n/i18n.ts +++ b/src/js/i18n/i18n.ts @@ -1,10 +1,11 @@ import i18next from 'i18next'; -import LanguageDetector from 'i18next-browser-languagedetector'; import HttpBackend from 'i18next-http-backend'; // Supported languages export const supportedLanguages = [ 'en', + 'ar', + 'be', 'fr', 'de', 'es', @@ -15,11 +16,17 @@ export const supportedLanguages = [ 'id', 'it', 'pt', + 'nl', + 'da', + 'sv', + 'ko', ] as const; export type SupportedLanguage = (typeof supportedLanguages)[number]; export const languageNames: Record = { en: 'English', + ar: 'العربية', + be: 'Беларуская', fr: 'Français', de: 'Deutsch', es: 'Español', @@ -30,6 +37,10 @@ export const languageNames: Record = { id: 'Bahasa Indonesia', it: 'Italiano', pt: 'Português', + nl: 'Nederlands', + da: 'Dansk', + sv: 'Svenska', + ko: '한국어', }; export const getLanguageFromUrl = (): SupportedLanguage => { @@ -45,7 +56,7 @@ export const getLanguageFromUrl = (): SupportedLanguage => { } const langMatch = path.match( - /^\/(en|fr|es|de|zh|zh-TW|vi|tr|id|it|pt)(?:\/|$)/ + /^\/(en|ar|fr|es|de|zh|zh-TW|vi|tr|id|it|pt|nl|be|da|ko)(?:\/|$)/ ); if ( langMatch && @@ -62,6 +73,25 @@ export const getLanguageFromUrl = (): SupportedLanguage => { return storedLang as SupportedLanguage; } + // Check browser language preferences + if (typeof navigator !== 'undefined' && navigator.languages) { + for (const lang of navigator.languages) { + if (supportedLanguages.includes(lang as SupportedLanguage)) { + return lang as SupportedLanguage; + } + + const primaryLang = lang.split('-')[0]; + if (supportedLanguages.includes(primaryLang as SupportedLanguage)) { + return primaryLang as SupportedLanguage; + } + } + } + + const envLang = import.meta.env?.VITE_DEFAULT_LANGUAGE; + if (envLang && supportedLanguages.includes(envLang as SupportedLanguage)) { + return envLang as SupportedLanguage; + } + return 'en'; }; @@ -72,27 +102,24 @@ export const initI18n = async (): Promise => { const currentLang = getLanguageFromUrl(); - await i18next - .use(HttpBackend) - .use(LanguageDetector) - .init({ - lng: currentLang, - fallbackLng: 'en', - supportedLngs: supportedLanguages as unknown as string[], - ns: ['common', 'tools'], - defaultNS: 'common', - backend: { - loadPath: `${import.meta.env.BASE_URL.replace(/\/?$/, '/')}locales/{{lng}}/{{ns}}.json`, - }, - detection: { - order: ['path', 'localStorage', 'navigator'], - lookupFromPathIndex: 0, - caches: ['localStorage'], - }, - interpolation: { - escapeValue: false, - }, - }); + localStorage.setItem('i18nextLng', currentLang); + + await i18next.use(HttpBackend).init({ + lng: currentLang, + fallbackLng: 'en', + supportedLngs: supportedLanguages as unknown as string[], + ns: ['common', 'tools'], + defaultNS: 'common', + preload: [currentLang], + backend: { + loadPath: `${import.meta.env.BASE_URL.replace(/\/?$/, '/')}locales/{{lng}}/{{ns}}.json`, + }, + interpolation: { + escapeValue: false, + }, + }); + + await i18next.loadNamespaces('tools'); initialized = true; return i18next; @@ -119,7 +146,7 @@ export const changeLanguage = (lang: SupportedLanguage): void => { let pagePathWithoutLang = relativePath; const langPrefixMatch = relativePath.match( - /^\/(en|fr|es|de|zh|zh-TW|vi|tr|id|it|pt)(\/.*)?$/ + /^\/(en|ar|fr|es|de|zh|zh-TW|vi|tr|id|it|pt|nl|be|da|ko)(\/.*)?$/ ); if (langPrefixMatch) { pagePathWithoutLang = langPrefixMatch[2] || '/'; @@ -182,6 +209,7 @@ export const applyTranslations = (): void => { }); document.documentElement.lang = i18next.language; + document.documentElement.dir = i18next.language === 'ar' ? 'rtl' : 'ltr'; }; export const rewriteLinks = (): void => { @@ -211,7 +239,7 @@ export const rewriteLinks = (): void => { } const langPrefixRegex = new RegExp( - `^(${basePath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})?/(en|fr|es|de|zh|zh-TW|vi|tr|id|it|pt)(/|$)` + `^(${basePath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})?/?(en|ar|fr|es|de|zh|zh-TW|vi|tr|id|it|pt|nl|be|da|ko)(/|$)` ); if (langPrefixRegex.test(href)) { return; @@ -234,7 +262,7 @@ export const rewriteLinks = (): void => { newHref = `/${currentLang}/`; } } else { - newHref = `${currentLang}/${href}`; + newHref = `/${currentLang}/${href}`; } newHref = newHref.replace(/([^:])\/+/g, '$1/'); diff --git a/src/js/logic/add-attachments-page.ts b/src/js/logic/add-attachments-page.ts index 966a64b..fbec042 100644 --- a/src/js/logic/add-attachments-page.ts +++ b/src/js/logic/add-attachments-page.ts @@ -3,349 +3,399 @@ import { showLoader, hideLoader, showAlert } from '../ui.js'; import { downloadFile, formatBytes } from '../utils/helpers.js'; import { createIcons, icons } from 'lucide'; import { PDFDocument as PDFLibDocument } from 'pdf-lib'; +import { isCpdfAvailable } from '../utils/cpdf-helper.js'; +import { + showWasmRequiredDialog, + WasmProvider, +} from '../utils/wasm-provider.js'; -const worker = new Worker(import.meta.env.BASE_URL + 'workers/add-attachments.worker.js'); +const worker = new Worker( + import.meta.env.BASE_URL + 'workers/add-attachments.worker.js' +); const pageState: AddAttachmentState = { - file: null, - pdfDoc: null, - attachments: [], + file: null, + pdfDoc: null, + attachments: [], }; function resetState() { - pageState.file = null; - pageState.pdfDoc = null; - pageState.attachments = []; + pageState.file = null; + pageState.pdfDoc = null; + pageState.attachments = []; - const fileDisplayArea = document.getElementById('file-display-area'); - if (fileDisplayArea) fileDisplayArea.innerHTML = ''; + const fileDisplayArea = document.getElementById('file-display-area'); + if (fileDisplayArea) fileDisplayArea.innerHTML = ''; - const toolOptions = document.getElementById('tool-options'); - if (toolOptions) toolOptions.classList.add('hidden'); + const toolOptions = document.getElementById('tool-options'); + if (toolOptions) toolOptions.classList.add('hidden'); - const attachmentFileList = document.getElementById('attachment-file-list'); - if (attachmentFileList) attachmentFileList.innerHTML = ''; + const attachmentFileList = document.getElementById('attachment-file-list'); + if (attachmentFileList) attachmentFileList.innerHTML = ''; - const attachmentInput = document.getElementById('attachment-files-input') as HTMLInputElement; - if (attachmentInput) attachmentInput.value = ''; + const attachmentInput = document.getElementById( + 'attachment-files-input' + ) as HTMLInputElement; + if (attachmentInput) attachmentInput.value = ''; - const attachmentLevelOptions = document.getElementById('attachment-level-options'); - if (attachmentLevelOptions) attachmentLevelOptions.classList.add('hidden'); + const attachmentLevelOptions = document.getElementById( + 'attachment-level-options' + ); + if (attachmentLevelOptions) attachmentLevelOptions.classList.add('hidden'); - const pageRangeWrapper = document.getElementById('page-range-wrapper'); - if (pageRangeWrapper) pageRangeWrapper.classList.add('hidden'); + const pageRangeWrapper = document.getElementById('page-range-wrapper'); + if (pageRangeWrapper) pageRangeWrapper.classList.add('hidden'); - const processBtn = document.getElementById('process-btn'); - if (processBtn) processBtn.classList.add('hidden'); + const processBtn = document.getElementById('process-btn'); + if (processBtn) processBtn.classList.add('hidden'); - const fileInput = document.getElementById('file-input') as HTMLInputElement; - if (fileInput) fileInput.value = ''; + const fileInput = document.getElementById('file-input') as HTMLInputElement; + if (fileInput) fileInput.value = ''; - const documentRadio = document.querySelector('input[name="attachment-level"][value="document"]') as HTMLInputElement; - if (documentRadio) documentRadio.checked = true; + const documentRadio = document.querySelector( + 'input[name="attachment-level"][value="document"]' + ) as HTMLInputElement; + if (documentRadio) documentRadio.checked = true; } worker.onmessage = function (e) { - const data = e.data; + const data = e.data; - if (data.status === 'success' && data.modifiedPDF !== undefined) { - hideLoader(); + if (data.status === 'success' && data.modifiedPDF !== undefined) { + hideLoader(); - const originalName = pageState.file?.name.replace(/\.pdf$/i, '') || 'document'; - downloadFile( - new Blob([new Uint8Array(data.modifiedPDF)], { type: 'application/pdf' }), - `${originalName}_with_attachments.pdf` - ); + const originalName = + pageState.file?.name.replace(/\.pdf$/i, '') || 'document'; + downloadFile( + new Blob([new Uint8Array(data.modifiedPDF)], { type: 'application/pdf' }), + `${originalName}_with_attachments.pdf` + ); - showAlert('Success', `${pageState.attachments.length} file(s) attached successfully.`, 'success', function () { - resetState(); - }); - } else if (data.status === 'error') { - hideLoader(); - showAlert('Error', data.message || 'Unknown error occurred.'); - } + showAlert( + 'Success', + `${pageState.attachments.length} file(s) attached successfully.`, + 'success', + function () { + resetState(); + } + ); + } else if (data.status === 'error') { + hideLoader(); + showAlert('Error', data.message || 'Unknown error occurred.'); + } }; worker.onerror = function (error) { - hideLoader(); - console.error('Worker error:', error); - showAlert('Error', 'Worker error occurred. Check console for details.'); + hideLoader(); + console.error('Worker error:', error); + showAlert('Error', 'Worker error occurred. Check console for details.'); }; async function updateUI() { - const fileDisplayArea = document.getElementById('file-display-area'); - const toolOptions = document.getElementById('tool-options'); + const fileDisplayArea = document.getElementById('file-display-area'); + const toolOptions = document.getElementById('tool-options'); - if (!fileDisplayArea) return; + if (!fileDisplayArea) return; - fileDisplayArea.innerHTML = ''; + fileDisplayArea.innerHTML = ''; - if (pageState.file) { - const fileDiv = document.createElement('div'); - fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm'; + if (pageState.file) { + const fileDiv = document.createElement('div'); + fileDiv.className = + 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm'; - const infoContainer = document.createElement('div'); - infoContainer.className = 'flex flex-col overflow-hidden'; + const infoContainer = document.createElement('div'); + infoContainer.className = 'flex flex-col overflow-hidden'; - const nameSpan = document.createElement('div'); - nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1'; - nameSpan.textContent = pageState.file.name; + const nameSpan = document.createElement('div'); + nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1'; + nameSpan.textContent = pageState.file.name; - const metaSpan = document.createElement('div'); - metaSpan.className = 'text-xs text-gray-400'; - metaSpan.textContent = `${formatBytes(pageState.file.size)} • Loading...`; + const metaSpan = document.createElement('div'); + metaSpan.className = 'text-xs text-gray-400'; + metaSpan.textContent = `${formatBytes(pageState.file.size)} • Loading...`; - infoContainer.append(nameSpan, metaSpan); + infoContainer.append(nameSpan, metaSpan); - const removeBtn = document.createElement('button'); - removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0'; - removeBtn.innerHTML = ''; - removeBtn.onclick = function () { - resetState(); - }; + const removeBtn = document.createElement('button'); + removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0'; + removeBtn.innerHTML = ''; + removeBtn.onclick = function () { + resetState(); + }; - fileDiv.append(infoContainer, removeBtn); - fileDisplayArea.appendChild(fileDiv); - createIcons({ icons }); + fileDiv.append(infoContainer, removeBtn); + fileDisplayArea.appendChild(fileDiv); + createIcons({ icons }); - try { - showLoader('Loading PDF...'); - const arrayBuffer = await pageState.file.arrayBuffer(); + try { + showLoader('Loading PDF...'); + const arrayBuffer = await pageState.file.arrayBuffer(); - pageState.pdfDoc = await PDFLibDocument.load(arrayBuffer, { - ignoreEncryption: true, - throwOnInvalidObject: false - }); + pageState.pdfDoc = await PDFLibDocument.load(arrayBuffer, { + ignoreEncryption: true, + throwOnInvalidObject: false, + }); - const pageCount = pageState.pdfDoc.getPageCount(); - metaSpan.textContent = `${formatBytes(pageState.file.size)} • ${pageCount} pages`; + const pageCount = pageState.pdfDoc.getPageCount(); + metaSpan.textContent = `${formatBytes(pageState.file.size)} • ${pageCount} pages`; - const totalPagesSpan = document.getElementById('attachment-total-pages'); - if (totalPagesSpan) totalPagesSpan.textContent = pageCount.toString(); + const totalPagesSpan = document.getElementById('attachment-total-pages'); + if (totalPagesSpan) totalPagesSpan.textContent = pageCount.toString(); - hideLoader(); + hideLoader(); - if (toolOptions) toolOptions.classList.remove('hidden'); - } catch (error) { - console.error('Error loading PDF:', error); - hideLoader(); - showAlert('Error', 'Failed to load PDF file.'); - resetState(); - } - } else { - if (toolOptions) toolOptions.classList.add('hidden'); + if (toolOptions) toolOptions.classList.remove('hidden'); + } catch (error) { + console.error('Error loading PDF:', error); + hideLoader(); + showAlert('Error', 'Failed to load PDF file.'); + resetState(); } + } else { + if (toolOptions) toolOptions.classList.add('hidden'); + } } function updateAttachmentList() { - const attachmentFileList = document.getElementById('attachment-file-list'); - const attachmentLevelOptions = document.getElementById('attachment-level-options'); - const processBtn = document.getElementById('process-btn'); + const attachmentFileList = document.getElementById('attachment-file-list'); + const attachmentLevelOptions = document.getElementById( + 'attachment-level-options' + ); + const processBtn = document.getElementById('process-btn'); - if (!attachmentFileList) return; + if (!attachmentFileList) return; - attachmentFileList.innerHTML = ''; + attachmentFileList.innerHTML = ''; - pageState.attachments.forEach(function (file) { - const div = document.createElement('div'); - div.className = 'flex justify-between items-center p-2 bg-gray-800 rounded-md text-white'; + pageState.attachments.forEach(function (file) { + const div = document.createElement('div'); + div.className = + 'flex justify-between items-center p-2 bg-gray-800 rounded-md text-white'; - const nameSpan = document.createElement('span'); - nameSpan.className = 'truncate text-sm'; - nameSpan.textContent = file.name; + const nameSpan = document.createElement('span'); + nameSpan.className = 'truncate text-sm'; + nameSpan.textContent = file.name; - const sizeSpan = document.createElement('span'); - sizeSpan.className = 'text-xs text-gray-400'; - sizeSpan.textContent = formatBytes(file.size); + const sizeSpan = document.createElement('span'); + sizeSpan.className = 'text-xs text-gray-400'; + sizeSpan.textContent = formatBytes(file.size); - div.append(nameSpan, sizeSpan); - attachmentFileList.appendChild(div); - }); + div.append(nameSpan, sizeSpan); + attachmentFileList.appendChild(div); + }); - if (pageState.attachments.length > 0) { - if (attachmentLevelOptions) attachmentLevelOptions.classList.remove('hidden'); - if (processBtn) processBtn.classList.remove('hidden'); - } else { - if (attachmentLevelOptions) attachmentLevelOptions.classList.add('hidden'); - if (processBtn) processBtn.classList.add('hidden'); - } + if (pageState.attachments.length > 0) { + if (attachmentLevelOptions) + attachmentLevelOptions.classList.remove('hidden'); + if (processBtn) processBtn.classList.remove('hidden'); + } else { + if (attachmentLevelOptions) attachmentLevelOptions.classList.add('hidden'); + if (processBtn) processBtn.classList.add('hidden'); + } } async function addAttachments() { - if (!pageState.file || !pageState.pdfDoc) { - showAlert('Error', 'Please upload a PDF first.'); - return; - } + if (!pageState.file || !pageState.pdfDoc) { + showAlert('Error', 'Please upload a PDF first.'); + return; + } - if (pageState.attachments.length === 0) { - showAlert('No Files', 'Please select at least one file to attach.'); - return; - } + if (pageState.attachments.length === 0) { + showAlert('No Files', 'Please select at least one file to attach.'); + return; + } - const attachmentLevel = ( - document.querySelector('input[name="attachment-level"]:checked') as HTMLInputElement + // Check if CPDF is configured + if (!isCpdfAvailable()) { + showWasmRequiredDialog('cpdf'); + return; + } + + const attachmentLevel = + ( + document.querySelector( + 'input[name="attachment-level"]:checked' + ) as HTMLInputElement )?.value || 'document'; - let pageRange: string = ''; + let pageRange: string = ''; - if (attachmentLevel === 'page') { - const pageRangeInput = document.getElementById('attachment-page-range') as HTMLInputElement; - pageRange = pageRangeInput?.value?.trim() || ''; + if (attachmentLevel === 'page') { + const pageRangeInput = document.getElementById( + 'attachment-page-range' + ) as HTMLInputElement; + pageRange = pageRangeInput?.value?.trim() || ''; - if (!pageRange) { - showAlert('Error', 'Please specify a page range for page-level attachments.'); - return; - } + if (!pageRange) { + showAlert( + 'Error', + 'Please specify a page range for page-level attachments.' + ); + return; + } + } + + showLoader('Embedding files into PDF...'); + + try { + const pdfBuffer = await pageState.file.arrayBuffer(); + + const attachmentBuffers: ArrayBuffer[] = []; + const attachmentNames: string[] = []; + + for (let i = 0; i < pageState.attachments.length; i++) { + const file = pageState.attachments[i]; + showLoader( + `Reading ${file.name} (${i + 1}/${pageState.attachments.length})...` + ); + + const fileBuffer = await file.arrayBuffer(); + attachmentBuffers.push(fileBuffer); + attachmentNames.push(file.name); } - showLoader('Embedding files into PDF...'); + showLoader('Attaching files to PDF...'); - try { - const pdfBuffer = await pageState.file.arrayBuffer(); + const message = { + command: 'add-attachments', + pdfBuffer: pdfBuffer, + attachmentBuffers: attachmentBuffers, + attachmentNames: attachmentNames, + attachmentLevel: attachmentLevel, + pageRange: pageRange, + cpdfUrl: WasmProvider.getUrl('cpdf')! + 'coherentpdf.browser.min.js', + }; - const attachmentBuffers: ArrayBuffer[] = []; - const attachmentNames: string[] = []; - - for (let i = 0; i < pageState.attachments.length; i++) { - const file = pageState.attachments[i]; - showLoader(`Reading ${file.name} (${i + 1}/${pageState.attachments.length})...`); - - const fileBuffer = await file.arrayBuffer(); - attachmentBuffers.push(fileBuffer); - attachmentNames.push(file.name); - } - - showLoader('Attaching files to PDF...'); - - const message = { - command: 'add-attachments', - pdfBuffer: pdfBuffer, - attachmentBuffers: attachmentBuffers, - attachmentNames: attachmentNames, - attachmentLevel: attachmentLevel, - pageRange: pageRange - }; - - const transferables = [pdfBuffer, ...attachmentBuffers]; - worker.postMessage(message, transferables); - - } catch (error: any) { - console.error('Error attaching files:', error); - hideLoader(); - showAlert('Error', `Failed to attach files: ${error.message}`); - } + const transferables = [pdfBuffer, ...attachmentBuffers]; + worker.postMessage(message, transferables); + } catch (error: any) { + console.error('Error attaching files:', error); + hideLoader(); + showAlert('Error', `Failed to attach files: ${error.message}`); + } } function handleFileSelect(files: FileList | null) { - if (files && files.length > 0) { - const file = files[0]; - if (file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf')) { - pageState.file = file; - updateUI(); - } + if (files && files.length > 0) { + const file = files[0]; + if ( + file.type === 'application/pdf' || + file.name.toLowerCase().endsWith('.pdf') + ) { + pageState.file = file; + updateUI(); } + } } function handleAttachmentSelect(files: FileList | null) { - if (files && files.length > 0) { - pageState.attachments = Array.from(files); - updateAttachmentList(); - } + if (files && files.length > 0) { + pageState.attachments = Array.from(files); + updateAttachmentList(); + } } document.addEventListener('DOMContentLoaded', function () { - const fileInput = document.getElementById('file-input') as HTMLInputElement; - const dropZone = document.getElementById('drop-zone'); - const attachmentInput = document.getElementById('attachment-files-input') as HTMLInputElement; - const attachmentDropZone = document.getElementById('attachment-drop-zone'); - const processBtn = document.getElementById('process-btn'); - const backBtn = document.getElementById('back-to-tools'); - const pageRangeWrapper = document.getElementById('page-range-wrapper'); + const fileInput = document.getElementById('file-input') as HTMLInputElement; + const dropZone = document.getElementById('drop-zone'); + const attachmentInput = document.getElementById( + 'attachment-files-input' + ) as HTMLInputElement; + const attachmentDropZone = document.getElementById('attachment-drop-zone'); + const processBtn = document.getElementById('process-btn'); + const backBtn = document.getElementById('back-to-tools'); + const pageRangeWrapper = document.getElementById('page-range-wrapper'); - if (backBtn) { - backBtn.addEventListener('click', function () { - window.location.href = import.meta.env.BASE_URL; - }); - } + if (backBtn) { + backBtn.addEventListener('click', function () { + window.location.href = import.meta.env.BASE_URL; + }); + } - if (fileInput && dropZone) { - fileInput.addEventListener('change', function (e) { - handleFileSelect((e.target as HTMLInputElement).files); - }); - - dropZone.addEventListener('dragover', function (e) { - e.preventDefault(); - dropZone.classList.add('bg-gray-700'); - }); - - dropZone.addEventListener('dragleave', function (e) { - e.preventDefault(); - dropZone.classList.remove('bg-gray-700'); - }); - - dropZone.addEventListener('drop', function (e) { - e.preventDefault(); - dropZone.classList.remove('bg-gray-700'); - const files = e.dataTransfer?.files; - if (files && files.length > 0) { - const pdfFiles = Array.from(files).filter(function (f) { - return f.type === 'application/pdf' || f.name.toLowerCase().endsWith('.pdf'); - }); - if (pdfFiles.length > 0) { - const dataTransfer = new DataTransfer(); - dataTransfer.items.add(pdfFiles[0]); - handleFileSelect(dataTransfer.files); - } - } - }); - - fileInput.addEventListener('click', function () { - fileInput.value = ''; - }); - } - - if (attachmentInput && attachmentDropZone) { - attachmentInput.addEventListener('change', function (e) { - handleAttachmentSelect((e.target as HTMLInputElement).files); - }); - - attachmentDropZone.addEventListener('dragover', function (e) { - e.preventDefault(); - attachmentDropZone.classList.add('bg-gray-700'); - }); - - attachmentDropZone.addEventListener('dragleave', function (e) { - e.preventDefault(); - attachmentDropZone.classList.remove('bg-gray-700'); - }); - - attachmentDropZone.addEventListener('drop', function (e) { - e.preventDefault(); - attachmentDropZone.classList.remove('bg-gray-700'); - const files = e.dataTransfer?.files; - if (files) { - handleAttachmentSelect(files); - } - }); - - attachmentInput.addEventListener('click', function () { - attachmentInput.value = ''; - }); - } - - const attachmentLevelRadios = document.querySelectorAll('input[name="attachment-level"]'); - attachmentLevelRadios.forEach(function (radio) { - radio.addEventListener('change', function (e) { - const value = (e.target as HTMLInputElement).value; - if (value === 'page' && pageRangeWrapper) { - pageRangeWrapper.classList.remove('hidden'); - } else if (pageRangeWrapper) { - pageRangeWrapper.classList.add('hidden'); - } - }); + if (fileInput && dropZone) { + fileInput.addEventListener('change', function (e) { + handleFileSelect((e.target as HTMLInputElement).files); }); - if (processBtn) { - processBtn.addEventListener('click', addAttachments); - } + dropZone.addEventListener('dragover', function (e) { + e.preventDefault(); + dropZone.classList.add('bg-gray-700'); + }); + + dropZone.addEventListener('dragleave', function (e) { + e.preventDefault(); + dropZone.classList.remove('bg-gray-700'); + }); + + dropZone.addEventListener('drop', function (e) { + e.preventDefault(); + dropZone.classList.remove('bg-gray-700'); + const files = e.dataTransfer?.files; + if (files && files.length > 0) { + const pdfFiles = Array.from(files).filter(function (f) { + return ( + f.type === 'application/pdf' || + f.name.toLowerCase().endsWith('.pdf') + ); + }); + if (pdfFiles.length > 0) { + const dataTransfer = new DataTransfer(); + dataTransfer.items.add(pdfFiles[0]); + handleFileSelect(dataTransfer.files); + } + } + }); + + fileInput.addEventListener('click', function () { + fileInput.value = ''; + }); + } + + if (attachmentInput && attachmentDropZone) { + attachmentInput.addEventListener('change', function (e) { + handleAttachmentSelect((e.target as HTMLInputElement).files); + }); + + attachmentDropZone.addEventListener('dragover', function (e) { + e.preventDefault(); + attachmentDropZone.classList.add('bg-gray-700'); + }); + + attachmentDropZone.addEventListener('dragleave', function (e) { + e.preventDefault(); + attachmentDropZone.classList.remove('bg-gray-700'); + }); + + attachmentDropZone.addEventListener('drop', function (e) { + e.preventDefault(); + attachmentDropZone.classList.remove('bg-gray-700'); + const files = e.dataTransfer?.files; + if (files) { + handleAttachmentSelect(files); + } + }); + + attachmentInput.addEventListener('click', function () { + attachmentInput.value = ''; + }); + } + + const attachmentLevelRadios = document.querySelectorAll( + 'input[name="attachment-level"]' + ); + attachmentLevelRadios.forEach(function (radio) { + radio.addEventListener('change', function (e) { + const value = (e.target as HTMLInputElement).value; + if (value === 'page' && pageRangeWrapper) { + pageRangeWrapper.classList.remove('hidden'); + } else if (pageRangeWrapper) { + pageRangeWrapper.classList.add('hidden'); + } + }); + }); + + if (processBtn) { + processBtn.addEventListener('click', addAttachments); + } }); diff --git a/src/js/logic/add-watermark-page.ts b/src/js/logic/add-watermark-page.ts index 7d366c9..38d0366 100644 --- a/src/js/logic/add-watermark-page.ts +++ b/src/js/logic/add-watermark-page.ts @@ -1,216 +1,1020 @@ import { createIcons, icons } from 'lucide'; import { showAlert, showLoader, hideLoader } from '../ui.js'; -import { downloadFile, hexToRgb, formatBytes, readFileAsArrayBuffer } from '../utils/helpers.js'; -import { PDFDocument as PDFLibDocument, rgb, degrees, StandardFonts } from 'pdf-lib'; -import { AddWatermarkState } from '@/types'; +import { + downloadFile, + hexToRgb, + formatBytes, + readFileAsArrayBuffer, +} from '../utils/helpers.js'; +import { PDFDocument as PDFLibDocument } from 'pdf-lib'; +import { + addTextWatermark, + addImageWatermark, + parsePageRange, +} from '../utils/pdf-operations.js'; +import { AddWatermarkState, PageWatermarkConfig } from '@/types'; +import * as pdfjsLib from 'pdfjs-dist'; -const pageState: AddWatermarkState = { file: null, pdfDoc: null }; +pdfjsLib.GlobalWorkerOptions.workerSrc = new URL( + 'pdfjs-dist/build/pdf.worker.min.mjs', + import.meta.url +).toString(); + +const pageState: AddWatermarkState = { + file: null, + pdfDoc: null, + pdfBytes: null, + previewCanvas: null, + watermarkX: 0.5, + watermarkY: 0.5, +}; + +let watermarkType: 'text' | 'image' = 'text'; +let imageWatermarkDataUrl: string | null = null; +let imageWatermarkFile: File | null = null; +let isDragging = false; +let dragOffsetX = 0; +let dragOffsetY = 0; +let previewScale = 1; +let pdfPageWidth = 0; +let pdfPageHeight = 0; +let isResizing = false; +let resizeStartDistance = 0; +let resizeStartFontSize = 0; +let resizeStartImageScale = 0; + +let currentPageNum = 1; +let totalPageCount = 1; +let cachedPdfjsDoc: pdfjsLib.PDFDocumentProxy | null = null; +const pageWatermarks: Map = new Map(); +let applyToAllPages = true; if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', initializePage); + document.addEventListener('DOMContentLoaded', initializePage); } else { - initializePage(); + initializePage(); } function initializePage() { - createIcons({ icons }); + createIcons({ icons }); - const fileInput = document.getElementById('file-input') as HTMLInputElement; - const dropZone = document.getElementById('drop-zone'); - const backBtn = document.getElementById('back-to-tools'); - const processBtn = document.getElementById('process-btn'); + const fileInput = document.getElementById('file-input') as HTMLInputElement; + const dropZone = document.getElementById('drop-zone'); + const backBtn = document.getElementById('back-to-tools'); + const editorBackBtn = document.getElementById('editor-back-btn'); + const processBtn = document.getElementById('process-btn'); - if (fileInput) { - fileInput.addEventListener('change', handleFileUpload); - fileInput.addEventListener('click', () => { fileInput.value = ''; }); - } + if (fileInput) { + fileInput.addEventListener('change', handleFileUpload); + 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'); - if (e.dataTransfer?.files.length) handleFiles(e.dataTransfer.files); - }); - } + 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'); + if (e.dataTransfer?.files.length) handleFiles(e.dataTransfer.files); + }); + } - if (backBtn) backBtn.addEventListener('click', () => { window.location.href = import.meta.env.BASE_URL; }); - if (processBtn) processBtn.addEventListener('click', addWatermark); + if (backBtn) + backBtn.addEventListener('click', () => { + window.location.href = import.meta.env.BASE_URL; + }); - setupWatermarkUI(); + if (editorBackBtn) + editorBackBtn.addEventListener('click', () => { + resetState(); + document.getElementById('uploader')?.classList.remove('hidden'); + document.getElementById('editor-panel')?.classList.add('hidden'); + }); + + if (processBtn) processBtn.addEventListener('click', applyWatermark); + + setupEditorControls(); + setupPageNavigation(); } function handleFileUpload(e: Event) { - const input = e.target as HTMLInputElement; - if (input.files?.length) handleFiles(input.files); + const input = e.target as HTMLInputElement; + if (input.files?.length) handleFiles(input.files); } async function handleFiles(files: FileList) { - const file = files[0]; - if (!file || file.type !== 'application/pdf') { - showAlert('Invalid File', 'Please upload a valid PDF file.'); - return; - } - showLoader('Loading PDF...'); - try { - const arrayBuffer = await file.arrayBuffer(); - pageState.pdfDoc = await PDFLibDocument.load(arrayBuffer); - pageState.file = file; - updateFileDisplay(); - document.getElementById('options-panel')?.classList.remove('hidden'); - } catch (error) { - console.error(error); - showAlert('Error', 'Failed to load PDF file.'); - } finally { - hideLoader(); + const file = files[0]; + if (!file || file.type !== 'application/pdf') { + showAlert('Invalid File', 'Please upload a valid PDF file.'); + return; + } + showLoader('Loading PDF...'); + try { + const arrayBuffer = await file.arrayBuffer(); + const pdfBytes = new Uint8Array(arrayBuffer); + pageState.pdfDoc = await PDFLibDocument.load(pdfBytes); + pageState.file = file; + pageState.pdfBytes = pdfBytes; + + cachedPdfjsDoc = await pdfjsLib.getDocument({ data: pdfBytes.slice() }) + .promise; + totalPageCount = cachedPdfjsDoc.numPages; + currentPageNum = 1; + pageWatermarks.clear(); + + updateFileDisplay(); + + document.getElementById('uploader')?.classList.add('hidden'); + document.getElementById('editor-panel')?.classList.remove('hidden'); + + const editorFileInfo = document.getElementById('editor-file-info'); + if (editorFileInfo) { + editorFileInfo.textContent = `${file.name} (${formatBytes(file.size)}, ${totalPageCount} pages)`; } + + updatePageNavUI(); + await renderPreview(); + updateWatermarkOverlay(); + } catch (error) { + console.error(error); + showAlert('Error', 'Failed to load PDF file.'); + } finally { + hideLoader(); + } } function updateFileDisplay() { - const fileDisplayArea = document.getElementById('file-display-area'); - if (!fileDisplayArea || !pageState.file || !pageState.pdfDoc) 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 = pageState.file.name; - const metaSpan = document.createElement('div'); - metaSpan.className = 'text-xs text-gray-400'; - metaSpan.textContent = `${formatBytes(pageState.file.size)} • ${pageState.pdfDoc.getPageCount()} pages`; - 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 }); + const fileDisplayArea = document.getElementById('file-display-area'); + if (!fileDisplayArea || !pageState.file || !pageState.pdfDoc) 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 = pageState.file.name; + const metaSpan = document.createElement('div'); + metaSpan.className = 'text-xs text-gray-400'; + metaSpan.textContent = `${formatBytes(pageState.file.size)} • ${pageState.pdfDoc.getPageCount()} pages`; + 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 }); } function resetState() { - pageState.file = null; - pageState.pdfDoc = null; - const fileDisplayArea = document.getElementById('file-display-area'); - if (fileDisplayArea) fileDisplayArea.innerHTML = ''; - document.getElementById('options-panel')?.classList.add('hidden'); - const fileInput = document.getElementById('file-input') as HTMLInputElement; - if (fileInput) fileInput.value = ''; + pageState.file = null; + pageState.pdfDoc = null; + pageState.pdfBytes = null; + pageState.previewCanvas = null; + pageState.watermarkX = 0.5; + pageState.watermarkY = 0.5; + imageWatermarkDataUrl = null; + imageWatermarkFile = null; + cachedPdfjsDoc = null; + currentPageNum = 1; + totalPageCount = 1; + pageWatermarks.clear(); + const fileDisplayArea = document.getElementById('file-display-area'); + if (fileDisplayArea) fileDisplayArea.innerHTML = ''; + const fileInput = document.getElementById('file-input') as HTMLInputElement; + if (fileInput) fileInput.value = ''; } -function setupWatermarkUI() { - const watermarkTypeRadios = document.querySelectorAll('input[name="watermark-type"]'); - const textOptions = document.getElementById('text-watermark-options'); - const imageOptions = document.getElementById('image-watermark-options'); +function setupPageNavigation() { + const prevBtn = document.getElementById('prev-page-btn'); + const nextBtn = document.getElementById('next-page-btn'); + const pageInput = document.getElementById( + 'page-num-input' + ) as HTMLInputElement; - watermarkTypeRadios.forEach((radio) => { - radio.addEventListener('change', (e) => { - const target = e.target as HTMLInputElement; - if (target.value === 'text') { - textOptions?.classList.remove('hidden'); - imageOptions?.classList.add('hidden'); - } else { - textOptions?.classList.add('hidden'); - imageOptions?.classList.remove('hidden'); - } - }); + prevBtn?.addEventListener('click', () => changePage(currentPageNum - 1)); + nextBtn?.addEventListener('click', () => changePage(currentPageNum + 1)); + + pageInput?.addEventListener('change', () => { + const val = parseInt(pageInput.value); + if (val >= 1 && val <= totalPageCount) { + changePage(val); + } else { + pageInput.value = String(currentPageNum); + } + }); + + pageInput?.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + (e.target as HTMLInputElement).blur(); + } + }); + + const applyAllCheckbox = document.getElementById( + 'apply-all-pages' + ) as HTMLInputElement; + const pageRangeSection = document.getElementById('page-range-section'); + applyAllCheckbox?.addEventListener('change', () => { + const wasApplyAll = applyToAllPages; + applyToAllPages = applyAllCheckbox.checked; + + if (pageRangeSection) { + pageRangeSection.style.display = applyToAllPages ? '' : 'none'; + } + + if (!applyToAllPages && wasApplyAll) { + const config = getCurrentConfig(); + for (let i = 1; i <= totalPageCount; i++) { + if (!pageWatermarks.has(i)) { + pageWatermarks.set(i, { ...config }); + } + } + } + }); +} + +function updatePageNavUI() { + const prevBtn = document.getElementById('prev-page-btn') as HTMLButtonElement; + const nextBtn = document.getElementById('next-page-btn') as HTMLButtonElement; + const pageInput = document.getElementById( + 'page-num-input' + ) as HTMLInputElement; + const totalSpan = document.getElementById('total-pages'); + + if (prevBtn) prevBtn.disabled = currentPageNum <= 1; + if (nextBtn) nextBtn.disabled = currentPageNum >= totalPageCount; + if (pageInput) { + pageInput.value = String(currentPageNum); + pageInput.max = String(totalPageCount); + } + if (totalSpan) totalSpan.textContent = String(totalPageCount); +} + +async function changePage(newPageNum: number) { + if (newPageNum < 1 || newPageNum > totalPageCount) return; + if (newPageNum === currentPageNum) return; + + saveCurrentPageConfig(); + currentPageNum = newPageNum; + updatePageNavUI(); + await renderPreview(); + loadPageConfig(currentPageNum); + updateWatermarkOverlay(); +} + +function getDefaultConfig(): PageWatermarkConfig { + return { + type: 'text', + x: 0.5, + y: 0.5, + text: '', + fontSize: 72, + color: '#888888', + opacityText: 0.3, + angleText: -45, + imageDataUrl: null, + imageFile: null, + imageScale: 100, + opacityImage: 0.3, + angleImage: 0, + }; +} + +function getCurrentConfig(): PageWatermarkConfig { + return { + type: watermarkType, + x: pageState.watermarkX, + y: pageState.watermarkY, + text: + (document.getElementById('watermark-text') as HTMLInputElement)?.value || + '', + fontSize: + parseInt( + (document.getElementById('font-size') as HTMLInputElement)?.value + ) || 72, + color: + (document.getElementById('text-color') as HTMLInputElement)?.value || + '#888888', + opacityText: + parseFloat( + (document.getElementById('opacity-text') as HTMLInputElement)?.value + ) || 0.3, + angleText: + parseInt( + (document.getElementById('angle-text') as HTMLInputElement)?.value + ) || 0, + imageDataUrl: imageWatermarkDataUrl, + imageFile: imageWatermarkFile, + imageScale: + parseInt( + (document.getElementById('image-scale') as HTMLInputElement)?.value + ) || 100, + opacityImage: + parseFloat( + (document.getElementById('opacity-image') as HTMLInputElement)?.value + ) || 0.3, + angleImage: + parseInt( + (document.getElementById('angle-image') as HTMLInputElement)?.value + ) || 0, + }; +} + +function saveCurrentPageConfig() { + pageWatermarks.set(currentPageNum, getCurrentConfig()); +} + +function loadPageConfig(pageNum: number) { + let config: PageWatermarkConfig; + + if (applyToAllPages) { + config = pageWatermarks.get(1) || getCurrentConfig(); + } else { + config = pageWatermarks.get(pageNum) || getDefaultConfig(); + } + + watermarkType = config.type; + pageState.watermarkX = config.x; + pageState.watermarkY = config.y; + imageWatermarkDataUrl = config.imageDataUrl; + imageWatermarkFile = config.imageFile; + + const typeTextBtn = document.getElementById('type-text-btn'); + const typeImageBtn = document.getElementById('type-image-btn'); + const textOptions = document.getElementById('text-watermark-options'); + const imageOptions = document.getElementById('image-watermark-options'); + + if (config.type === 'text') { + typeTextBtn!.className = + 'flex-1 py-2 px-3 text-sm font-medium rounded-lg bg-indigo-600 text-white transition-colors'; + typeImageBtn!.className = + 'flex-1 py-2 px-3 text-sm font-medium rounded-lg bg-gray-700 text-gray-300 hover:bg-gray-600 transition-colors'; + textOptions?.classList.remove('hidden'); + imageOptions?.classList.add('hidden'); + } else { + typeImageBtn!.className = + 'flex-1 py-2 px-3 text-sm font-medium rounded-lg bg-indigo-600 text-white transition-colors'; + typeTextBtn!.className = + 'flex-1 py-2 px-3 text-sm font-medium rounded-lg bg-gray-700 text-gray-300 hover:bg-gray-600 transition-colors'; + textOptions?.classList.add('hidden'); + imageOptions?.classList.remove('hidden'); + } + + const watermarkText = document.getElementById( + 'watermark-text' + ) as HTMLInputElement; + const fontSize = document.getElementById('font-size') as HTMLInputElement; + const textColor = document.getElementById('text-color') as HTMLInputElement; + const opacityText = document.getElementById( + 'opacity-text' + ) as HTMLInputElement; + const angleText = document.getElementById('angle-text') as HTMLInputElement; + const opacityValueText = document.getElementById('opacity-value-text'); + const angleValueText = document.getElementById('angle-value-text'); + + if (watermarkText) watermarkText.value = config.text; + if (fontSize) fontSize.value = String(config.fontSize); + if (textColor) textColor.value = config.color; + if (opacityText) opacityText.value = String(config.opacityText); + if (angleText) angleText.value = String(config.angleText); + if (opacityValueText) + opacityValueText.textContent = String(config.opacityText); + if (angleValueText) angleValueText.textContent = String(config.angleText); + + const imageScale = document.getElementById('image-scale') as HTMLInputElement; + const opacityImage = document.getElementById( + 'opacity-image' + ) as HTMLInputElement; + const angleImage = document.getElementById('angle-image') as HTMLInputElement; + const imageScaleValue = document.getElementById('image-scale-value'); + const opacityValueImage = document.getElementById('opacity-value-image'); + const angleValueImage = document.getElementById('angle-value-image'); + + if (imageScale) imageScale.value = String(config.imageScale); + if (opacityImage) opacityImage.value = String(config.opacityImage); + if (angleImage) angleImage.value = String(config.angleImage); + if (imageScaleValue) imageScaleValue.textContent = String(config.imageScale); + if (opacityValueImage) + opacityValueImage.textContent = String(config.opacityImage); + if (angleValueImage) angleValueImage.textContent = String(config.angleImage); + + updatePresetHighlight(config.x, config.y); +} + +async function renderPreview() { + if (!pageState.pdfBytes || !cachedPdfjsDoc) return; + + const page = await cachedPdfjsDoc.getPage(currentPageNum); + + const container = document.getElementById('preview-container'); + const wrapper = document.getElementById('preview-wrapper'); + if (!container || !wrapper) return; + + const isDesktop = window.innerWidth >= 1024; + const availableWidth = wrapper.clientWidth - 16; + let availableHeight: number; + + if (isDesktop) { + const controlsCard = document.querySelector( + '.lg\\:w-80 > div' + ) as HTMLElement; + if (controlsCard && controlsCard.offsetHeight > 100) { + const cardHeader = wrapper.parentElement?.querySelector( + '.flex.items-center.justify-between' + ) as HTMLElement; + const headerH = cardHeader ? cardHeader.offsetHeight + 12 : 40; + const cardPadding = 32; + availableHeight = controlsCard.offsetHeight - headerH - cardPadding; + } else { + availableHeight = + wrapper.clientHeight > 100 + ? wrapper.clientHeight - 16 + : window.innerHeight * 0.8; + } + } else { + availableHeight = Math.min(window.innerHeight * 0.55, 600); + } + + const unscaledViewport = page.getViewport({ scale: 1 }); + pdfPageWidth = unscaledViewport.width; + pdfPageHeight = unscaledViewport.height; + + previewScale = Math.min( + availableWidth / pdfPageWidth, + availableHeight / pdfPageHeight + ); + const displayWidth = Math.floor(pdfPageWidth * previewScale); + const displayHeight = Math.floor(pdfPageHeight * previewScale); + + const dpr = 2; + const viewport = page.getViewport({ scale: previewScale * dpr }); + + const canvas = document.getElementById('preview-canvas') as HTMLCanvasElement; + if (!canvas) return; + + canvas.width = viewport.width; + canvas.height = viewport.height; + canvas.style.width = displayWidth + 'px'; + canvas.style.height = displayHeight + 'px'; + + container.style.width = displayWidth + 'px'; + container.style.height = displayHeight + 'px'; + + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + ctx.fillStyle = '#ffffff'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + await page.render({ canvasContext: ctx, canvas, viewport }).promise; + + pageState.previewCanvas = canvas; + setupDragHandlers(container); +} + +function setupEditorControls() { + const typeTextBtn = document.getElementById('type-text-btn'); + const typeImageBtn = document.getElementById('type-image-btn'); + const textOptions = document.getElementById('text-watermark-options'); + const imageOptions = document.getElementById('image-watermark-options'); + + typeTextBtn?.addEventListener('click', () => { + watermarkType = 'text'; + typeTextBtn.className = + 'flex-1 py-2 px-3 text-sm font-medium rounded-lg bg-indigo-600 text-white transition-colors'; + typeImageBtn!.className = + 'flex-1 py-2 px-3 text-sm font-medium rounded-lg bg-gray-700 text-gray-300 hover:bg-gray-600 transition-colors'; + textOptions?.classList.remove('hidden'); + imageOptions?.classList.add('hidden'); + updateWatermarkOverlay(); + }); + + typeImageBtn?.addEventListener('click', () => { + watermarkType = 'image'; + typeImageBtn.className = + 'flex-1 py-2 px-3 text-sm font-medium rounded-lg bg-indigo-600 text-white transition-colors'; + typeTextBtn!.className = + 'flex-1 py-2 px-3 text-sm font-medium rounded-lg bg-gray-700 text-gray-300 hover:bg-gray-600 transition-colors'; + textOptions?.classList.add('hidden'); + imageOptions?.classList.remove('hidden'); + updateWatermarkOverlay(); + }); + + const watermarkText = document.getElementById( + 'watermark-text' + ) as HTMLInputElement; + const fontSize = document.getElementById('font-size') as HTMLInputElement; + const textColor = document.getElementById('text-color') as HTMLInputElement; + const opacityText = document.getElementById( + 'opacity-text' + ) as HTMLInputElement; + const angleText = document.getElementById('angle-text') as HTMLInputElement; + const opacityValueText = document.getElementById('opacity-value-text'); + const angleValueText = document.getElementById('angle-value-text'); + + watermarkText?.addEventListener('input', () => updateWatermarkOverlay()); + fontSize?.addEventListener('input', () => updateWatermarkOverlay()); + textColor?.addEventListener('input', () => updateWatermarkOverlay()); + + opacityText?.addEventListener('input', () => { + if (opacityValueText) opacityValueText.textContent = opacityText.value; + updateWatermarkOverlay(); + }); + + angleText?.addEventListener('input', () => { + if (angleValueText) angleValueText.textContent = angleText.value; + updateWatermarkOverlay(); + }); + + const opacityImage = document.getElementById( + 'opacity-image' + ) as HTMLInputElement; + const angleImage = document.getElementById('angle-image') as HTMLInputElement; + const imageScale = document.getElementById('image-scale') as HTMLInputElement; + const opacityValueImage = document.getElementById('opacity-value-image'); + const angleValueImage = document.getElementById('angle-value-image'); + const imageScaleValue = document.getElementById('image-scale-value'); + + opacityImage?.addEventListener('input', () => { + if (opacityValueImage) opacityValueImage.textContent = opacityImage.value; + updateWatermarkOverlay(); + }); + + angleImage?.addEventListener('input', () => { + if (angleValueImage) angleValueImage.textContent = angleImage.value; + updateWatermarkOverlay(); + }); + + imageScale?.addEventListener('input', () => { + if (imageScaleValue) imageScaleValue.textContent = imageScale.value; + updateWatermarkOverlay(); + }); + + const imageInput = document.getElementById( + 'image-watermark-input' + ) as HTMLInputElement; + imageInput?.addEventListener('change', () => { + const file = imageInput.files?.[0]; + if (!file) return; + imageWatermarkFile = file; + const reader = new FileReader(); + reader.onload = () => { + imageWatermarkDataUrl = reader.result as string; + updateWatermarkOverlay(); + }; + reader.readAsDataURL(file); + }); + + document.querySelectorAll('.pos-preset-btn').forEach((btn) => { + btn.addEventListener('click', () => { + const pos = (btn as HTMLElement).dataset.pos; + if (!pos) return; + const [x, y] = pos.split(',').map(Number); + pageState.watermarkX = x; + pageState.watermarkY = y; + updatePresetHighlight(x, y); + updateWatermarkOverlay(); }); - - const opacitySliderText = document.getElementById('opacity-text') as HTMLInputElement; - const opacityValueText = document.getElementById('opacity-value-text'); - const angleSliderText = document.getElementById('angle-text') as HTMLInputElement; - const angleValueText = document.getElementById('angle-value-text'); - - opacitySliderText?.addEventListener('input', () => { if (opacityValueText) opacityValueText.textContent = opacitySliderText.value; }); - angleSliderText?.addEventListener('input', () => { if (angleValueText) angleValueText.textContent = angleSliderText.value; }); - - const opacitySliderImage = document.getElementById('opacity-image') as HTMLInputElement; - const opacityValueImage = document.getElementById('opacity-value-image'); - const angleSliderImage = document.getElementById('angle-image') as HTMLInputElement; - const angleValueImage = document.getElementById('angle-value-image'); - - opacitySliderImage?.addEventListener('input', () => { if (opacityValueImage) opacityValueImage.textContent = opacitySliderImage.value; }); - angleSliderImage?.addEventListener('input', () => { if (angleValueImage) angleValueImage.textContent = angleSliderImage.value; }); + }); } -async function addWatermark() { - if (!pageState.pdfDoc) { - showAlert('Error', 'Please upload a PDF file first.'); - return; +function updatePresetHighlight(x: number, y: number) { + document.querySelectorAll('.pos-preset-btn').forEach((btn) => { + const pos = (btn as HTMLElement).dataset.pos; + if (!pos) return; + const [bx, by] = pos.split(',').map(Number); + if (Math.abs(bx - x) < 0.01 && Math.abs(by - y) < 0.01) { + btn.className = + 'pos-preset-btn py-1.5 text-xs bg-indigo-600 hover:bg-indigo-500 text-white rounded-md transition-colors'; + } else { + btn.className = + 'pos-preset-btn py-1.5 text-xs bg-gray-700 hover:bg-gray-600 text-gray-300 rounded-md transition-colors'; + } + }); +} + +function updateWatermarkOverlay() { + const box = document.getElementById('watermark-box') as HTMLElement; + const textOverlay = document.getElementById( + 'watermark-overlay' + ) as HTMLElement; + const imageOverlay = document.getElementById( + 'image-watermark-overlay' + ) as HTMLImageElement; + if (!box || !textOverlay || !imageOverlay) return; + + const container = document.getElementById('preview-container'); + if (!container) return; + + const containerW = container.clientWidth; + const containerH = container.clientHeight; + + if (watermarkType === 'text') { + box.classList.remove('hidden'); + textOverlay.classList.remove('hidden'); + imageOverlay.classList.add('hidden'); + + const text = + (document.getElementById('watermark-text') as HTMLInputElement)?.value || + 'CONFIDENTIAL'; + const fontSizePdf = + parseInt( + (document.getElementById('font-size') as HTMLInputElement)?.value + ) || 72; + const color = + (document.getElementById('text-color') as HTMLInputElement)?.value || + '#888888'; + const opacity = + parseFloat( + (document.getElementById('opacity-text') as HTMLInputElement)?.value + ) || 0.3; + const angle = + parseInt( + (document.getElementById('angle-text') as HTMLInputElement)?.value + ) || 0; + + const fontSizePreview = fontSizePdf * previewScale; + + textOverlay.textContent = text; + textOverlay.style.fontSize = fontSizePreview + 'px'; + textOverlay.style.color = color; + textOverlay.style.opacity = String(opacity); + textOverlay.style.fontFamily = + '"Noto Sans SC", "Noto Sans JP", "Noto Sans KR", "Noto Sans Arabic", sans-serif'; + textOverlay.style.fontWeight = 'bold'; + + box.style.left = pageState.watermarkX * containerW + 'px'; + box.style.top = pageState.watermarkY * containerH + 'px'; + box.style.transform = `translate(-50%, -50%) rotate(${angle}deg)`; + } else { + textOverlay.classList.add('hidden'); + + if (imageWatermarkDataUrl) { + box.classList.remove('hidden'); + imageOverlay.classList.remove('hidden'); + imageOverlay.src = imageWatermarkDataUrl; + + const scale = + parseInt( + (document.getElementById('image-scale') as HTMLInputElement)?.value + ) || 100; + const opacity = + parseFloat( + (document.getElementById('opacity-image') as HTMLInputElement)?.value + ) || 0.3; + const angle = + parseInt( + (document.getElementById('angle-image') as HTMLInputElement)?.value + ) || 0; + + imageOverlay.style.opacity = String(opacity); + imageOverlay.style.maxWidth = (scale / 100) * containerW * 0.5 + 'px'; + + box.style.left = pageState.watermarkX * containerW + 'px'; + box.style.top = pageState.watermarkY * containerH + 'px'; + box.style.transform = `translate(-50%, -50%) rotate(${angle}deg)`; + } else { + box.classList.add('hidden'); + imageOverlay.classList.add('hidden'); + } + } +} + +function setupDragHandlers(container: HTMLElement) { + const box = document.getElementById('watermark-box')!; + + function onPointerDown(e: PointerEvent) { + const target = e.target as HTMLElement; + + if (target.classList.contains('resize-handle')) { + isResizing = true; + const containerRect = container.getBoundingClientRect(); + const centerX = pageState.watermarkX * containerRect.width; + const centerY = pageState.watermarkY * containerRect.height; + const pointerX = e.clientX - containerRect.left; + const pointerY = e.clientY - containerRect.top; + resizeStartDistance = Math.max( + Math.hypot(pointerX - centerX, pointerY - centerY), + 10 + ); + + if (watermarkType === 'text') { + resizeStartFontSize = + parseInt( + (document.getElementById('font-size') as HTMLInputElement).value + ) || 72; + } else { + resizeStartImageScale = + parseInt( + (document.getElementById('image-scale') as HTMLInputElement).value + ) || 100; + } + + container.setPointerCapture(e.pointerId); + e.preventDefault(); + return; } - const watermarkType = (document.querySelector('input[name="watermark-type"]:checked') as HTMLInputElement)?.value || 'text'; - showLoader('Adding watermark...'); + if (!box.contains(target)) return; - try { - const pages = pageState.pdfDoc.getPages(); - let watermarkAsset: any = null; + isDragging = true; + const rect = box.getBoundingClientRect(); + dragOffsetX = e.clientX - rect.left - rect.width / 2; + dragOffsetY = e.clientY - rect.top - rect.height / 2; + container.setPointerCapture(e.pointerId); + e.preventDefault(); + } - if (watermarkType === 'text') { - watermarkAsset = await pageState.pdfDoc.embedFont(StandardFonts.Helvetica); + function onPointerMove(e: PointerEvent) { + if (isResizing) { + const containerRect = container.getBoundingClientRect(); + const centerX = pageState.watermarkX * containerRect.width; + const centerY = pageState.watermarkY * containerRect.height; + const pointerX = e.clientX - containerRect.left; + const pointerY = e.clientY - containerRect.top; + const currentDistance = Math.hypot( + pointerX - centerX, + pointerY - centerY + ); + const ratio = currentDistance / resizeStartDistance; + + if (watermarkType === 'text') { + const newSize = Math.max( + 10, + Math.min(200, Math.round(resizeStartFontSize * ratio)) + ); + const fontSizeInput = document.getElementById( + 'font-size' + ) as HTMLInputElement; + fontSizeInput.value = String(newSize); + } else { + const newScale = Math.max( + 10, + Math.min(200, Math.round(resizeStartImageScale * ratio)) + ); + const scaleInput = document.getElementById( + 'image-scale' + ) as HTMLInputElement; + scaleInput.value = String(newScale); + const scaleValue = document.getElementById('image-scale-value'); + if (scaleValue) scaleValue.textContent = String(newScale); + } + + updateWatermarkOverlay(); + e.preventDefault(); + return; + } + + if (!isDragging) return; + + const containerRect = container.getBoundingClientRect(); + const x = e.clientX - containerRect.left - dragOffsetX; + const y = e.clientY - containerRect.top - dragOffsetY; + + const clampedX = Math.max(0, Math.min(x, containerRect.width)); + const clampedY = Math.max(0, Math.min(y, containerRect.height)); + + pageState.watermarkX = clampedX / containerRect.width; + pageState.watermarkY = clampedY / containerRect.height; + + updatePresetHighlight(-1, -1); + updateWatermarkOverlay(); + e.preventDefault(); + } + + function onPointerUp() { + isDragging = false; + isResizing = false; + } + + container.addEventListener('pointerdown', onPointerDown); + container.addEventListener('pointermove', onPointerMove); + container.addEventListener('pointerup', onPointerUp); + container.addEventListener('pointercancel', onPointerUp); +} + +async function applyWatermark() { + if (!pageState.pdfDoc || !pageState.pdfBytes) { + showAlert('Error', 'Please upload a PDF file first.'); + return; + } + + showLoader('Adding watermark...'); + + try { + saveCurrentPageConfig(); + const pdfBytes = new Uint8Array(await pageState.pdfDoc.save()); + let resultBytes = pdfBytes; + + if (applyToAllPages) { + const config = getCurrentConfig(); + const posY = 1 - config.y; + + const pageRangeStr = + ( + document.getElementById('page-range-input') as HTMLInputElement + )?.value.trim() || 'all'; + const pageIndices = + pageRangeStr.toLowerCase() === 'all' + ? undefined + : parsePageRange(pageRangeStr, pageState.pdfDoc!.getPageCount()); + + if (config.type === 'text') { + const text = config.text; + if (!text.trim()) + throw new Error('Please enter text for the watermark.'); + const textColor = hexToRgb(config.color); + + resultBytes = new Uint8Array( + await addTextWatermark(resultBytes, { + text, + fontSize: config.fontSize, + color: textColor, + opacity: config.opacityText, + angle: -config.angleText, + x: config.x, + y: posY, + pageIndices, + }) + ); + } else { + const imageFile = config.imageFile; + if (!imageFile) + throw new Error('Please select an image file for the watermark.'); + const imageBytes = await readFileAsArrayBuffer(imageFile); + + let imageType: 'png' | 'jpg'; + if (imageFile.type === 'image/png') { + imageType = 'png'; + } else if (imageFile.type === 'image/jpeg') { + imageType = 'jpg'; } else { - const imageFile = (document.getElementById('image-watermark-input') as HTMLInputElement).files?.[0]; - if (!imageFile) throw new Error('Please select an image file for the watermark.'); - const imageBytes = await readFileAsArrayBuffer(imageFile); - if (imageFile.type === 'image/png') { - watermarkAsset = await pageState.pdfDoc.embedPng(imageBytes as ArrayBuffer); - } else if (imageFile.type === 'image/jpeg') { - watermarkAsset = await pageState.pdfDoc.embedJpg(imageBytes as ArrayBuffer); - } else { - throw new Error('Unsupported Image. Please use a PNG or JPG for the watermark.'); - } + throw new Error( + 'Unsupported Image. Please use a PNG or JPG for the watermark.' + ); } - for (const page of pages) { - const { width, height } = page.getSize(); + resultBytes = new Uint8Array( + await addImageWatermark(resultBytes, { + imageBytes: new Uint8Array(imageBytes as ArrayBuffer), + imageType, + opacity: config.opacityImage, + angle: -config.angleImage, + scale: config.imageScale / 100, + x: config.x, + y: posY, + pageIndices, + }) + ); + } + } else { + const configGroups: Map< + string, + { config: PageWatermarkConfig; indices: number[] } + > = new Map(); - if (watermarkType === 'text') { - const text = (document.getElementById('watermark-text') as HTMLInputElement).value; - if (!text.trim()) throw new Error('Please enter text for the watermark.'); - const fontSize = parseInt((document.getElementById('font-size') as HTMLInputElement).value) || 72; - const angle = parseInt((document.getElementById('angle-text') as HTMLInputElement).value) || 0; - const opacity = parseFloat((document.getElementById('opacity-text') as HTMLInputElement).value) || 0.3; - const colorHex = (document.getElementById('text-color') as HTMLInputElement).value; - const textColor = hexToRgb(colorHex); - const textWidth = watermarkAsset.widthOfTextAtSize(text, fontSize); + for (let i = 1; i <= totalPageCount; i++) { + const config = pageWatermarks.get(i); + if (!config) continue; - page.drawText(text, { - x: (width - textWidth) / 2, - y: height / 2, - font: watermarkAsset, - size: fontSize, - color: rgb(textColor.r, textColor.g, textColor.b), - opacity: opacity, - rotate: degrees(angle), - }); - } else { - const angle = parseInt((document.getElementById('angle-image') as HTMLInputElement).value) || 0; - const opacity = parseFloat((document.getElementById('opacity-image') as HTMLInputElement).value) || 0.3; - const scale = 0.5; - const imgWidth = watermarkAsset.width * scale; - const imgHeight = watermarkAsset.height * scale; + const hasContent = + config.type === 'text' + ? config.text.trim().length > 0 + : config.imageFile !== null; + if (!hasContent) continue; - page.drawImage(watermarkAsset, { - x: (width - imgWidth) / 2, - y: (height - imgHeight) / 2, - width: imgWidth, - height: imgHeight, - opacity: opacity, - rotate: degrees(angle), - }); - } + const key = JSON.stringify({ + type: config.type, + x: config.x, + y: config.y, + text: config.text, + fontSize: config.fontSize, + color: config.color, + opacityText: config.opacityText, + angleText: config.angleText, + imageScale: config.imageScale, + opacityImage: config.opacityImage, + angleImage: config.angleImage, + imageFileName: config.imageFile?.name || null, + }); + + if (!configGroups.has(key)) { + configGroups.set(key, { config, indices: [] }); } + configGroups.get(key)!.indices.push(i - 1); + } - const newPdfBytes = await pageState.pdfDoc.save(); - downloadFile(new Blob([new Uint8Array(newPdfBytes)], { type: 'application/pdf' }), 'watermarked.pdf'); - showAlert('Success', 'Watermark added successfully!', 'success', () => { resetState(); }); - } catch (e: any) { - console.error(e); - showAlert('Error', e.message || 'Could not add the watermark.'); - } finally { - hideLoader(); + for (const { config, indices } of configGroups.values()) { + const posY = 1 - config.y; + + if (config.type === 'text') { + const textColor = hexToRgb(config.color); + resultBytes = new Uint8Array( + await addTextWatermark(resultBytes, { + text: config.text, + fontSize: config.fontSize, + color: textColor, + opacity: config.opacityText, + angle: -config.angleText, + x: config.x, + y: posY, + pageIndices: indices, + }) + ); + } else { + if (!config.imageFile) continue; + const imageBytes = await readFileAsArrayBuffer(config.imageFile); + + let imageType: 'png' | 'jpg'; + if (config.imageFile.type === 'image/png') { + imageType = 'png'; + } else if (config.imageFile.type === 'image/jpeg') { + imageType = 'jpg'; + } else { + continue; + } + + resultBytes = new Uint8Array( + await addImageWatermark(resultBytes, { + imageBytes: new Uint8Array(imageBytes as ArrayBuffer), + imageType, + opacity: config.opacityImage, + angle: -config.angleImage, + scale: config.imageScale / 100, + x: config.x, + y: posY, + pageIndices: indices, + }) + ); + } + } } + + const flattenCheckbox = document.getElementById( + 'flatten-watermark' + ) as HTMLInputElement; + if (flattenCheckbox?.checked) { + const watermarkedPdf = await pdfjsLib.getDocument({ + data: resultBytes.slice(), + }).promise; + const flattenedDoc = await PDFLibDocument.create(); + const totalPages = watermarkedPdf.numPages; + const renderScale = 2.5; + + for (let i = 1; i <= totalPages; i++) { + showLoader(`Flattening page ${i} of ${totalPages}...`); + const page = await watermarkedPdf.getPage(i); + const unscaledVP = page.getViewport({ scale: 1 }); + const viewport = page.getViewport({ scale: renderScale }); + + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d')!; + canvas.width = viewport.width; + canvas.height = viewport.height; + await page.render({ canvasContext: ctx, canvas, viewport }).promise; + + const jpegBytes = await new Promise((resolve) => + canvas.toBlob( + (blob) => blob?.arrayBuffer().then(resolve), + 'image/jpeg', + 0.92 + ) + ); + + const image = await flattenedDoc.embedJpg(jpegBytes); + const newPage = flattenedDoc.addPage([ + unscaledVP.width, + unscaledVP.height, + ]); + newPage.drawImage(image, { + x: 0, + y: 0, + width: unscaledVP.width, + height: unscaledVP.height, + }); + } + + resultBytes = new Uint8Array(await flattenedDoc.save()); + } + + downloadFile( + new Blob([new Uint8Array(resultBytes)], { type: 'application/pdf' }), + 'watermarked.pdf' + ); + showAlert('Success', 'Watermark added successfully!', 'success'); + } catch (e: any) { + console.error(e); + showAlert('Error', e.message || 'Could not add the watermark.'); + } finally { + hideLoader(); + } } diff --git a/src/js/logic/adjust-colors-page.ts b/src/js/logic/adjust-colors-page.ts new file mode 100644 index 0000000..59ece2d --- /dev/null +++ b/src/js/logic/adjust-colors-page.ts @@ -0,0 +1,406 @@ +import { showLoader, hideLoader, showAlert } from '../ui.js'; +import { + downloadFile, + formatBytes, + readFileAsArrayBuffer, + getPDFDocument, +} from '../utils/helpers.js'; +import { createIcons, icons } from 'lucide'; +import { PDFDocument } from 'pdf-lib'; +import { applyColorAdjustments } from '../utils/image-effects.js'; +import * as pdfjsLib from 'pdfjs-dist'; +import type { AdjustColorsSettings } from '../types/adjust-colors-type.js'; + +pdfjsLib.GlobalWorkerOptions.workerSrc = new URL( + 'pdfjs-dist/build/pdf.worker.min.mjs', + import.meta.url +).toString(); + +let files: File[] = []; +let cachedBaselineData: ImageData | null = null; +let cachedBaselineWidth = 0; +let cachedBaselineHeight = 0; +let pdfjsDoc: pdfjsLib.PDFDocumentProxy | null = null; + +function getSettings(): AdjustColorsSettings { + return { + brightness: parseInt( + (document.getElementById('setting-brightness') as HTMLInputElement) + ?.value ?? '0' + ), + contrast: parseInt( + (document.getElementById('setting-contrast') as HTMLInputElement) + ?.value ?? '0' + ), + saturation: parseInt( + (document.getElementById('setting-saturation') as HTMLInputElement) + ?.value ?? '0' + ), + hueShift: parseInt( + (document.getElementById('setting-hue-shift') as HTMLInputElement) + ?.value ?? '0' + ), + temperature: parseInt( + (document.getElementById('setting-temperature') as HTMLInputElement) + ?.value ?? '0' + ), + tint: parseInt( + (document.getElementById('setting-tint') as HTMLInputElement)?.value ?? + '0' + ), + gamma: parseFloat( + (document.getElementById('setting-gamma') as HTMLInputElement)?.value ?? + '1.0' + ), + sepia: parseInt( + (document.getElementById('setting-sepia') as HTMLInputElement)?.value ?? + '0' + ), + }; +} + +const applyEffects = applyColorAdjustments; + +function updatePreview(): void { + if (!cachedBaselineData) return; + + const previewCanvas = document.getElementById( + 'preview-canvas' + ) as HTMLCanvasElement; + if (!previewCanvas) return; + + const settings = getSettings(); + const baselineCopy = new ImageData( + new Uint8ClampedArray(cachedBaselineData.data), + cachedBaselineWidth, + cachedBaselineHeight + ); + + applyEffects(baselineCopy, previewCanvas, settings); +} + +async function renderPreview(): Promise { + if (!pdfjsDoc) return; + + const page = await pdfjsDoc.getPage(1); + const viewport = page.getViewport({ scale: 1.0 }); + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d')!; + canvas.width = viewport.width; + canvas.height = viewport.height; + + await page.render({ canvasContext: ctx, viewport, canvas }).promise; + + cachedBaselineData = ctx.getImageData(0, 0, canvas.width, canvas.height); + cachedBaselineWidth = canvas.width; + cachedBaselineHeight = canvas.height; + + updatePreview(); +} + +const updateUI = () => { + const fileDisplayArea = document.getElementById('file-display-area'); + const optionsPanel = document.getElementById('options-panel'); + + if (!fileDisplayArea || !optionsPanel) return; + + fileDisplayArea.innerHTML = ''; + + if (files.length > 0) { + optionsPanel.classList.remove('hidden'); + + files.forEach((file) => { + const fileDiv = document.createElement('div'); + fileDiv.className = + 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm'; + + const infoContainer = document.createElement('div'); + infoContainer.className = 'flex flex-col overflow-hidden'; + + const nameSpan = document.createElement('div'); + nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1'; + nameSpan.textContent = file.name; + + const metaSpan = document.createElement('div'); + metaSpan.className = 'text-xs text-gray-400'; + metaSpan.textContent = `${formatBytes(file.size)} • Loading pages...`; + + 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 = () => { + files = []; + pdfjsDoc = null; + cachedBaselineData = null; + updateUI(); + }; + + fileDiv.append(infoContainer, removeBtn); + fileDisplayArea.appendChild(fileDiv); + + readFileAsArrayBuffer(file) + .then((buffer: ArrayBuffer) => { + return getPDFDocument(buffer).promise; + }) + .then((pdf: pdfjsLib.PDFDocumentProxy) => { + metaSpan.textContent = `${formatBytes(file.size)} • ${pdf.numPages} page${pdf.numPages !== 1 ? 's' : ''}`; + }) + .catch(() => { + metaSpan.textContent = formatBytes(file.size); + }); + }); + + createIcons({ icons }); + } else { + optionsPanel.classList.add('hidden'); + } +}; + +const resetState = () => { + files = []; + pdfjsDoc = null; + cachedBaselineData = null; + const fileInput = document.getElementById('file-input') as HTMLInputElement; + if (fileInput) fileInput.value = ''; + updateUI(); +}; + +async function processAllPages(): Promise { + if (files.length === 0) { + showAlert('No File', 'Please upload a PDF file first.'); + return; + } + + showLoader('Applying color adjustments...'); + + try { + const settings = getSettings(); + const pdfBytes = (await readFileAsArrayBuffer(files[0])) as ArrayBuffer; + const doc = await getPDFDocument({ data: pdfBytes }).promise; + const newPdfDoc = await PDFDocument.create(); + + for (let i = 1; i <= doc.numPages; i++) { + showLoader(`Processing page ${i} of ${doc.numPages}...`); + + const page = await doc.getPage(i); + const viewport = page.getViewport({ scale: 2.0 }); + const renderCanvas = document.createElement('canvas'); + const renderCtx = renderCanvas.getContext('2d')!; + renderCanvas.width = viewport.width; + renderCanvas.height = viewport.height; + + await page.render({ + canvasContext: renderCtx, + viewport, + canvas: renderCanvas, + }).promise; + + const baseData = renderCtx.getImageData( + 0, + 0, + renderCanvas.width, + renderCanvas.height + ); + + const outputCanvas = document.createElement('canvas'); + applyEffects(baseData, outputCanvas, settings); + + const pngBlob = await new Promise((resolve) => + outputCanvas.toBlob(resolve, 'image/png') + ); + + if (pngBlob) { + const pngBytes = await pngBlob.arrayBuffer(); + const pngImage = await newPdfDoc.embedPng(pngBytes); + const origViewport = page.getViewport({ scale: 1.0 }); + const newPage = newPdfDoc.addPage([ + origViewport.width, + origViewport.height, + ]); + newPage.drawImage(pngImage, { + x: 0, + y: 0, + width: origViewport.width, + height: origViewport.height, + }); + } + } + + const resultBytes = await newPdfDoc.save(); + downloadFile( + new Blob([new Uint8Array(resultBytes)], { type: 'application/pdf' }), + 'color-adjusted.pdf' + ); + showAlert( + 'Success', + 'Color adjustments applied successfully!', + 'success', + () => { + resetState(); + } + ); + } catch (e) { + console.error(e); + showAlert( + 'Error', + 'Failed to apply color adjustments. The file might be corrupted.' + ); + } finally { + hideLoader(); + } +} + +const sliderDefaults: { + id: string; + display: string; + suffix: string; + defaultValue: string; +}[] = [ + { + id: 'setting-brightness', + display: 'brightness-value', + suffix: '', + defaultValue: '0', + }, + { + id: 'setting-contrast', + display: 'contrast-value', + suffix: '', + defaultValue: '0', + }, + { + id: 'setting-saturation', + display: 'saturation-value', + suffix: '', + defaultValue: '0', + }, + { + id: 'setting-hue-shift', + display: 'hue-shift-value', + suffix: '°', + defaultValue: '0', + }, + { + id: 'setting-temperature', + display: 'temperature-value', + suffix: '', + defaultValue: '0', + }, + { id: 'setting-tint', display: 'tint-value', suffix: '', defaultValue: '0' }, + { + id: 'setting-gamma', + display: 'gamma-value', + suffix: '', + defaultValue: '1.0', + }, + { + id: 'setting-sepia', + display: 'sepia-value', + suffix: '', + defaultValue: '0', + }, +]; + +function resetSettings(): void { + sliderDefaults.forEach(({ id, display, suffix, defaultValue }) => { + const slider = document.getElementById(id) as HTMLInputElement; + const label = document.getElementById(display); + if (slider) slider.value = defaultValue; + if (label) label.textContent = defaultValue + suffix; + }); + + updatePreview(); +} + +function setupSettingsListeners(): void { + sliderDefaults.forEach(({ id, display, suffix }) => { + const slider = document.getElementById(id) as HTMLInputElement; + const label = document.getElementById(display); + if (slider && label) { + slider.addEventListener('input', () => { + label.textContent = slider.value + suffix; + updatePreview(); + }); + } + }); + + const resetBtn = document.getElementById('reset-settings-btn'); + if (resetBtn) { + resetBtn.addEventListener('click', resetSettings); + } +} + +document.addEventListener('DOMContentLoaded', () => { + const fileInput = document.getElementById('file-input') as HTMLInputElement; + const dropZone = document.getElementById('drop-zone'); + const processBtn = document.getElementById('process-btn'); + const backBtn = document.getElementById('back-to-tools'); + + if (backBtn) { + backBtn.addEventListener('click', () => { + window.location.href = import.meta.env.BASE_URL; + }); + } + + const handleFileSelect = async (newFiles: FileList | null) => { + if (!newFiles || newFiles.length === 0) return; + const validFiles = Array.from(newFiles).filter( + (file) => file.type === 'application/pdf' + ); + + if (validFiles.length === 0) { + showAlert('Invalid File', 'Please upload a PDF file.'); + return; + } + + files = [validFiles[0]]; + updateUI(); + + showLoader('Loading preview...'); + try { + const buffer = await readFileAsArrayBuffer(validFiles[0]); + pdfjsDoc = await getPDFDocument({ data: buffer }).promise; + await renderPreview(); + } catch (e) { + console.error(e); + showAlert('Error', 'Failed to load PDF for preview.'); + } finally { + hideLoader(); + } + }; + + if (fileInput && dropZone) { + fileInput.addEventListener('change', (e) => { + handleFileSelect((e.target as HTMLInputElement).files); + }); + + dropZone.addEventListener('dragover', (e) => { + e.preventDefault(); + dropZone.classList.add('bg-gray-700'); + }); + + dropZone.addEventListener('dragleave', (e) => { + e.preventDefault(); + dropZone.classList.remove('bg-gray-700'); + }); + + dropZone.addEventListener('drop', (e) => { + e.preventDefault(); + dropZone.classList.remove('bg-gray-700'); + handleFileSelect(e.dataTransfer?.files ?? null); + }); + + fileInput.addEventListener('click', () => { + fileInput.value = ''; + }); + } + + if (processBtn) { + processBtn.addEventListener('click', processAllPages); + } + + setupSettingsListeners(); +}); diff --git a/src/js/logic/alternate-merge-page.ts b/src/js/logic/alternate-merge-page.ts index 712575d..6b32aed 100644 --- a/src/js/logic/alternate-merge-page.ts +++ b/src/js/logic/alternate-merge-page.ts @@ -3,245 +3,278 @@ import { showLoader, hideLoader, showAlert } from '../ui.js'; import { downloadFile, formatBytes, getPDFDocument } from '../utils/helpers.js'; import { createIcons, icons } from 'lucide'; import Sortable from 'sortablejs'; +import { isCpdfAvailable } from '../utils/cpdf-helper.js'; +import { + showWasmRequiredDialog, + WasmProvider, +} from '../utils/wasm-provider.js'; const pageState: AlternateMergeState = { - files: [], - pdfBytes: new Map(), - pdfDocs: new Map(), + files: [], + pdfBytes: new Map(), + pdfDocs: new Map(), }; -const alternateMergeWorker = new Worker(import.meta.env.BASE_URL + 'workers/alternate-merge.worker.js'); +const alternateMergeWorker = new Worker( + import.meta.env.BASE_URL + 'workers/alternate-merge.worker.js' +); function resetState() { - pageState.files = []; - pageState.pdfBytes.clear(); - pageState.pdfDocs.clear(); + pageState.files = []; + pageState.pdfBytes.clear(); + pageState.pdfDocs.clear(); - const fileDisplayArea = document.getElementById('file-display-area'); - if (fileDisplayArea) fileDisplayArea.innerHTML = ''; + const fileDisplayArea = document.getElementById('file-display-area'); + if (fileDisplayArea) fileDisplayArea.innerHTML = ''; - const fileList = document.getElementById('file-list'); - if (fileList) fileList.innerHTML = ''; + const fileList = document.getElementById('file-list'); + if (fileList) fileList.innerHTML = ''; - const toolOptions = document.getElementById('tool-options'); - if (toolOptions) toolOptions.classList.add('hidden'); + const toolOptions = document.getElementById('tool-options'); + if (toolOptions) toolOptions.classList.add('hidden'); - const fileInput = document.getElementById('file-input') as HTMLInputElement; - if (fileInput) fileInput.value = ''; + const fileInput = document.getElementById('file-input') as HTMLInputElement; + if (fileInput) fileInput.value = ''; } async function updateUI() { - const fileDisplayArea = document.getElementById('file-display-area'); - const toolOptions = document.getElementById('tool-options'); - const fileList = document.getElementById('file-list'); + const fileDisplayArea = document.getElementById('file-display-area'); + const toolOptions = document.getElementById('tool-options'); + const fileList = document.getElementById('file-list'); - if (!fileDisplayArea || !fileList) return; + if (!fileDisplayArea || !fileList) return; - fileDisplayArea.innerHTML = ''; + fileDisplayArea.innerHTML = ''; - if (pageState.files.length > 0) { - // Show file count summary - const summaryDiv = document.createElement('div'); - summaryDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm'; + if (pageState.files.length > 0) { + // Show file count summary + const summaryDiv = document.createElement('div'); + summaryDiv.className = + 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm'; - const infoSpan = document.createElement('span'); - infoSpan.className = 'text-gray-200'; - infoSpan.textContent = `${pageState.files.length} PDF files selected`; + const infoSpan = document.createElement('span'); + infoSpan.className = 'text-gray-200'; + infoSpan.textContent = `${pageState.files.length} PDF files selected`; - const clearBtn = document.createElement('button'); - clearBtn.className = 'text-red-400 hover:text-red-300'; - clearBtn.innerHTML = ''; - clearBtn.onclick = function () { - resetState(); - }; + const clearBtn = document.createElement('button'); + clearBtn.className = 'text-red-400 hover:text-red-300'; + clearBtn.innerHTML = ''; + clearBtn.onclick = function () { + resetState(); + }; - summaryDiv.append(infoSpan, clearBtn); - fileDisplayArea.appendChild(summaryDiv); - createIcons({ icons }); + summaryDiv.append(infoSpan, clearBtn); + fileDisplayArea.appendChild(summaryDiv); + createIcons({ icons }); - // Load PDFs and populate list - showLoader('Loading PDF files...'); - fileList.innerHTML = ''; + // Load PDFs and populate list + showLoader('Loading PDF files...'); + fileList.innerHTML = ''; - try { - for (const file of pageState.files) { - const arrayBuffer = await file.arrayBuffer(); - pageState.pdfBytes.set(file.name, arrayBuffer); + try { + for (const file of pageState.files) { + const arrayBuffer = await file.arrayBuffer(); + pageState.pdfBytes.set(file.name, arrayBuffer); - const bytesForPdfJs = arrayBuffer.slice(0); - const pdfjsDoc = await getPDFDocument({ data: bytesForPdfJs }).promise; - pageState.pdfDocs.set(file.name, pdfjsDoc); - const pageCount = pdfjsDoc.numPages; + const bytesForPdfJs = arrayBuffer.slice(0); + const pdfjsDoc = await getPDFDocument({ data: bytesForPdfJs }).promise; + pageState.pdfDocs.set(file.name, pdfjsDoc); + const pageCount = pdfjsDoc.numPages; - const li = document.createElement('li'); - li.className = 'bg-gray-700 p-3 rounded-lg border border-gray-600 flex items-center justify-between'; - li.dataset.fileName = file.name; + const li = document.createElement('li'); + li.className = + 'bg-gray-700 p-3 rounded-lg border border-gray-600 flex items-center justify-between'; + li.dataset.fileName = file.name; - const infoDiv = document.createElement('div'); - infoDiv.className = 'flex items-center gap-2 truncate flex-1'; + const infoDiv = document.createElement('div'); + infoDiv.className = 'flex items-center gap-2 truncate flex-1'; - const nameSpan = document.createElement('span'); - nameSpan.className = 'truncate font-medium text-white'; - nameSpan.textContent = file.name; + const nameSpan = document.createElement('span'); + nameSpan.className = 'truncate font-medium text-white'; + nameSpan.textContent = file.name; - const metaSpan = document.createElement('span'); - metaSpan.className = 'text-sm text-gray-400 flex-shrink-0'; - metaSpan.textContent = `${formatBytes(file.size)} • ${pageCount} pages`; + const metaSpan = document.createElement('span'); + metaSpan.className = 'text-sm text-gray-400 flex-shrink-0'; + metaSpan.textContent = `${formatBytes(file.size)} • ${pageCount} pages`; - infoDiv.append(nameSpan, metaSpan); + infoDiv.append(nameSpan, metaSpan); - const dragHandle = document.createElement('div'); - dragHandle.className = 'drag-handle cursor-move text-gray-400 hover:text-white p-1 rounded ml-2'; - dragHandle.innerHTML = ``; + const dragHandle = document.createElement('div'); + dragHandle.className = + 'drag-handle cursor-move text-gray-400 hover:text-white p-1 rounded ml-2'; + dragHandle.innerHTML = ``; - li.append(infoDiv, dragHandle); - fileList.appendChild(li); - } + li.append(infoDiv, dragHandle); + fileList.appendChild(li); + } - Sortable.create(fileList, { - handle: '.drag-handle', - animation: 150, - }); + Sortable.create(fileList, { + handle: '.drag-handle', + animation: 150, + }); - hideLoader(); + hideLoader(); - if (toolOptions && pageState.files.length >= 2) { - toolOptions.classList.remove('hidden'); - } - } catch (error) { - console.error('Error loading PDFs:', error); - hideLoader(); - showAlert('Error', 'Failed to load one or more PDF files.'); - resetState(); - } - } else { - if (toolOptions) toolOptions.classList.add('hidden'); + if (toolOptions && pageState.files.length >= 2) { + toolOptions.classList.remove('hidden'); + } + } catch (error) { + console.error('Error loading PDFs:', error); + hideLoader(); + showAlert('Error', 'Failed to load one or more PDF files.'); + resetState(); } + } else { + if (toolOptions) toolOptions.classList.add('hidden'); + } } async function mixPages() { - if (pageState.pdfBytes.size < 2) { - showAlert('Not Enough Files', 'Please upload at least two PDF files to alternate and mix.'); - return; + if (pageState.pdfBytes.size < 2) { + showAlert( + 'Not Enough Files', + 'Please upload at least two PDF files to alternate and mix.' + ); + return; + } + + // Check if CPDF is configured + if (!isCpdfAvailable()) { + showWasmRequiredDialog('cpdf'); + return; + } + + showLoader('Alternating and mixing pages...'); + + try { + const fileList = document.getElementById('file-list'); + if (!fileList) throw new Error('File list not found'); + + const sortedFileNames = Array.from(fileList.children) + .map(function (li) { + return (li as HTMLElement).dataset.fileName; + }) + .filter(Boolean) as string[]; + + interface InterleaveFile { + name: string; + data: ArrayBuffer; } - showLoader('Alternating and mixing pages...'); - - try { - const fileList = document.getElementById('file-list'); - if (!fileList) throw new Error('File list not found'); - - const sortedFileNames = Array.from(fileList.children).map(function (li) { - return (li as HTMLElement).dataset.fileName; - }).filter(Boolean) as string[]; - - interface InterleaveFile { - name: string; - data: ArrayBuffer; - } - - const filesToMerge: InterleaveFile[] = []; - for (const name of sortedFileNames) { - const bytes = pageState.pdfBytes.get(name); - if (bytes) { - filesToMerge.push({ name, data: bytes }); - } - } - - if (filesToMerge.length < 2) { - showAlert('Error', 'At least two valid PDFs are required.'); - hideLoader(); - return; - } - - const message = { - command: 'interleave', - files: filesToMerge - }; - - alternateMergeWorker.postMessage(message, filesToMerge.map(function (f) { return f.data; })); - - alternateMergeWorker.onmessage = function (e: MessageEvent) { - hideLoader(); - if (e.data.status === 'success') { - const blob = new Blob([e.data.pdfBytes], { type: 'application/pdf' }); - downloadFile(blob, 'alternated-mixed.pdf'); - showAlert('Success', 'PDFs have been mixed successfully!', 'success', function () { - resetState(); - }); - } else { - console.error('Worker interleave error:', e.data.message); - showAlert('Error', e.data.message || 'Failed to interleave PDFs.'); - } - }; - - alternateMergeWorker.onerror = function (e) { - hideLoader(); - console.error('Worker error:', e); - showAlert('Error', 'An unexpected error occurred in the merge worker.'); - }; - - } catch (e) { - console.error('Alternate Merge error:', e); - showAlert('Error', 'An error occurred while mixing the PDFs.'); - hideLoader(); + const filesToMerge: InterleaveFile[] = []; + for (const name of sortedFileNames) { + const bytes = pageState.pdfBytes.get(name); + if (bytes) { + filesToMerge.push({ name, data: bytes }); + } } + + if (filesToMerge.length < 2) { + showAlert('Error', 'At least two valid PDFs are required.'); + hideLoader(); + return; + } + + const message = { + command: 'interleave', + files: filesToMerge, + cpdfUrl: WasmProvider.getUrl('cpdf')! + 'coherentpdf.browser.min.js', + }; + + alternateMergeWorker.postMessage( + message, + filesToMerge.map(function (f) { + return f.data; + }) + ); + + alternateMergeWorker.onmessage = function (e: MessageEvent) { + hideLoader(); + if (e.data.status === 'success') { + const blob = new Blob([e.data.pdfBytes], { type: 'application/pdf' }); + downloadFile(blob, 'alternated-mixed.pdf'); + showAlert( + 'Success', + 'PDFs have been mixed successfully!', + 'success', + function () { + resetState(); + } + ); + } else { + console.error('Worker interleave error:', e.data.message); + showAlert('Error', e.data.message || 'Failed to interleave PDFs.'); + } + }; + + alternateMergeWorker.onerror = function (e) { + hideLoader(); + console.error('Worker error:', e); + showAlert('Error', 'An unexpected error occurred in the merge worker.'); + }; + } catch (e) { + console.error('Alternate Merge error:', e); + showAlert('Error', 'An error occurred while mixing the PDFs.'); + hideLoader(); + } } function handleFileSelect(files: FileList | null) { - if (files && files.length > 0) { - const pdfFiles = Array.from(files).filter(function (f) { - return f.type === 'application/pdf' || f.name.toLowerCase().endsWith('.pdf'); - }); - if (pdfFiles.length > 0) { - pageState.files = pdfFiles; - updateUI(); - } + if (files && files.length > 0) { + const pdfFiles = Array.from(files).filter(function (f) { + return ( + f.type === 'application/pdf' || f.name.toLowerCase().endsWith('.pdf') + ); + }); + if (pdfFiles.length > 0) { + pageState.files = pdfFiles; + updateUI(); } + } } document.addEventListener('DOMContentLoaded', function () { - const fileInput = document.getElementById('file-input') as HTMLInputElement; - const dropZone = document.getElementById('drop-zone'); - const processBtn = document.getElementById('process-btn'); - const backBtn = document.getElementById('back-to-tools'); + const fileInput = document.getElementById('file-input') as HTMLInputElement; + const dropZone = document.getElementById('drop-zone'); + const processBtn = document.getElementById('process-btn'); + const backBtn = document.getElementById('back-to-tools'); - if (backBtn) { - backBtn.addEventListener('click', function () { - window.location.href = import.meta.env.BASE_URL; - }); - } + if (backBtn) { + backBtn.addEventListener('click', function () { + window.location.href = import.meta.env.BASE_URL; + }); + } - if (fileInput && dropZone) { - fileInput.addEventListener('change', function (e) { - handleFileSelect((e.target as HTMLInputElement).files); - }); + if (fileInput && dropZone) { + fileInput.addEventListener('change', function (e) { + handleFileSelect((e.target as HTMLInputElement).files); + }); - dropZone.addEventListener('dragover', function (e) { - e.preventDefault(); - dropZone.classList.add('bg-gray-700'); - }); + dropZone.addEventListener('dragover', function (e) { + e.preventDefault(); + dropZone.classList.add('bg-gray-700'); + }); - dropZone.addEventListener('dragleave', function (e) { - e.preventDefault(); - dropZone.classList.remove('bg-gray-700'); - }); + dropZone.addEventListener('dragleave', function (e) { + e.preventDefault(); + dropZone.classList.remove('bg-gray-700'); + }); - dropZone.addEventListener('drop', function (e) { - e.preventDefault(); - dropZone.classList.remove('bg-gray-700'); - const files = e.dataTransfer?.files; - if (files && files.length > 0) { - handleFileSelect(files); - } - }); + dropZone.addEventListener('drop', function (e) { + e.preventDefault(); + dropZone.classList.remove('bg-gray-700'); + const files = e.dataTransfer?.files; + if (files && files.length > 0) { + handleFileSelect(files); + } + }); - fileInput.addEventListener('click', function () { - fileInput.value = ''; - }); - } + fileInput.addEventListener('click', function () { + fileInput.value = ''; + }); + } - if (processBtn) { - processBtn.addEventListener('click', mixPages); - } + if (processBtn) { + processBtn.addEventListener('click', mixPages); + } }); diff --git a/src/js/logic/bates-numbering-page.ts b/src/js/logic/bates-numbering-page.ts new file mode 100644 index 0000000..fe093ac --- /dev/null +++ b/src/js/logic/bates-numbering-page.ts @@ -0,0 +1,548 @@ +import { createIcons, icons } from 'lucide'; +import { showAlert, showLoader, hideLoader } from '../ui.js'; +import { downloadFile, hexToRgb, formatBytes } from '../utils/helpers.js'; +import { PDFDocument, StandardFonts, rgb } from 'pdf-lib'; +import JSZip from 'jszip'; +import Sortable from 'sortablejs'; +import { FileEntry, Position, StylePreset } from '@/types'; + +const FONT_MAP: Record = { + Helvetica: 'Helvetica', + TimesRoman: 'TimesRoman', + Courier: 'Courier', +}; + +const STYLE_PRESETS: Record = { + 'full-6': { + template: 'Exhibit [FILE] Case XYZ [BATES] Page [PAGE]', + padding: 6, + }, + 'full-5': { + template: 'Exhibit [FILE] Case XYZ [BATES] Page [PAGE]', + padding: 5, + }, + 'full-4': { + template: 'Exhibit [FILE] Case XYZ [BATES] Page [PAGE]', + padding: 4, + }, + 'full-3': { + template: 'Exhibit [FILE] Case XYZ [BATES] Page [PAGE]', + padding: 3, + }, + 'full-0': { + template: 'Exhibit [FILE] Case XYZ [BATES] Page [PAGE]', + padding: 0, + }, + 'no-page-6': { template: 'Exhibit [FILE] Case XYZ [BATES]', padding: 6 }, + 'no-page-5': { template: 'Exhibit [FILE] Case XYZ [BATES]', padding: 5 }, + 'no-page-4': { template: 'Exhibit [FILE] Case XYZ [BATES]', padding: 4 }, + 'no-page-3': { template: 'Exhibit [FILE] Case XYZ [BATES]', padding: 3 }, + 'no-page-0': { template: 'Exhibit [FILE] Case XYZ [BATES]', padding: 0 }, + 'case-6': { template: 'Case XYZ [BATES]', padding: 6 }, + 'case-5': { template: 'Case XYZ [BATES]', padding: 5 }, + 'case-4': { template: 'Case XYZ [BATES]', padding: 4 }, + 'case-3': { template: 'Case XYZ [BATES]', padding: 3 }, + 'case-0': { template: 'Case XYZ [BATES]', padding: 0 }, + 'bates-6': { template: '[BATES]', padding: 6 }, + 'bates-5': { template: '[BATES]', padding: 5 }, + 'bates-4': { template: '[BATES]', padding: 4 }, + 'bates-3': { template: '[BATES]', padding: 3 }, + 'bates-0': { template: '[BATES]', padding: 0 }, +}; + +const files: FileEntry[] = []; + +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initializePage); +} else { + initializePage(); +} + +function initializePage() { + createIcons({ icons }); + + const fileInput = document.getElementById('file-input') as HTMLInputElement; + const dropZone = document.getElementById('drop-zone'); + const backBtn = document.getElementById('back-to-tools'); + const processBtn = document.getElementById('process-btn'); + const addMoreBtn = document.getElementById('add-more-btn'); + const clearFilesBtn = document.getElementById('clear-files-btn'); + const stylePreset = document.getElementById( + 'style-preset' + ) as HTMLSelectElement; + const templateInput = document.getElementById( + 'bates-template' + ) as HTMLInputElement; + + if (fileInput) { + fileInput.addEventListener('change', () => { + if (fileInput.files?.length) { + handleFiles(fileInput.files); + 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'); + if (e.dataTransfer?.files.length) handleFiles(e.dataTransfer.files); + }); + } + + if (backBtn) { + backBtn.addEventListener('click', () => { + window.location.href = import.meta.env.BASE_URL; + }); + } + + if (processBtn) { + processBtn.addEventListener('click', applyBatesNumbers); + } + + if (addMoreBtn) { + addMoreBtn.addEventListener('click', () => fileInput?.click()); + } + + if (clearFilesBtn) { + clearFilesBtn.addEventListener('click', resetState); + } + + if (stylePreset) { + stylePreset.addEventListener('change', () => { + const value = stylePreset.value; + const isCustom = value === 'custom'; + const paddingGroup = document.getElementById('padding-group'); + if (!isCustom && STYLE_PRESETS[value]) { + templateInput.value = STYLE_PRESETS[value].template; + if (paddingGroup) paddingGroup.classList.add('hidden'); + } else { + if (paddingGroup) paddingGroup.classList.remove('hidden'); + } + templateInput.readOnly = !isCustom; + updatePreview(); + }); + } + + if (templateInput) { + templateInput.addEventListener('input', () => { + const preset = stylePreset; + if (preset && preset.value !== 'custom') { + preset.value = 'custom'; + templateInput.readOnly = false; + document.getElementById('padding-group')?.classList.remove('hidden'); + } + updatePreview(); + }); + } + + document + .getElementById('bates-padding') + ?.addEventListener('change', updatePreview); + document + .getElementById('bates-start') + ?.addEventListener('input', updatePreview); + document + .getElementById('file-start') + ?.addEventListener('input', updatePreview); + + initSortable(); +} + +function initSortable() { + const fileList = document.getElementById('file-list'); + if (!fileList) return; + Sortable.create(fileList, { + handle: '.drag-handle', + animation: 150, + onEnd: (evt) => { + if (evt.oldIndex !== undefined && evt.newIndex !== undefined) { + const [moved] = files.splice(evt.oldIndex, 1); + files.splice(evt.newIndex, 0, moved); + updatePreview(); + } + }, + }); +} + +async function handleFiles(fileList: FileList) { + showLoader('Loading PDFs...'); + try { + for (const file of Array.from(fileList)) { + if (file.type !== 'application/pdf') continue; + const arrayBuffer = await file.arrayBuffer(); + const pdfDoc = await PDFDocument.load(arrayBuffer); + files.push({ file, pageCount: pdfDoc.getPageCount() }); + } + + if (files.length === 0) { + showAlert('Invalid File', 'Please upload valid PDF files.'); + return; + } + + renderFileList(); + document.getElementById('options-panel')?.classList.remove('hidden'); + document.getElementById('file-controls')?.classList.remove('hidden'); + updatePreview(); + } catch (error) { + console.error(error); + showAlert('Error', 'Failed to load one or more PDF files.'); + } finally { + hideLoader(); + } +} + +function renderFileList() { + const fileListEl = document.getElementById('file-list'); + if (!fileListEl) return; + + fileListEl.innerHTML = ''; + let totalPages = 0; + + files.forEach((entry, index) => { + totalPages += entry.pageCount; + + const fileDiv = document.createElement('div'); + fileDiv.className = + 'flex items-center justify-between bg-gray-700 p-3 rounded-lg'; + + const leftSection = document.createElement('div'); + leftSection.className = 'flex items-center gap-3 flex-1 min-w-0'; + + const dragHandle = document.createElement('i'); + dragHandle.setAttribute('data-lucide', 'grip-vertical'); + dragHandle.className = + 'drag-handle w-4 h-4 text-gray-400 cursor-grab flex-shrink-0'; + + const infoContainer = document.createElement('div'); + infoContainer.className = 'flex flex-col min-w-0'; + + const nameSpan = document.createElement('div'); + nameSpan.className = 'truncate font-medium text-gray-200 text-sm'; + nameSpan.textContent = entry.file.name; + + const metaSpan = document.createElement('div'); + metaSpan.className = 'text-xs text-gray-400'; + metaSpan.textContent = `${formatBytes(entry.file.size)} \u2022 ${entry.pageCount} pages`; + + infoContainer.append(nameSpan, metaSpan); + leftSection.append(dragHandle, infoContainer); + + const removeBtn = document.createElement('button'); + removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0'; + removeBtn.innerHTML = ''; + removeBtn.onclick = () => { + files.splice(index, 1); + renderFileList(); + updatePreview(); + if (files.length === 0) resetState(); + }; + + fileDiv.append(leftSection, removeBtn); + fileListEl.appendChild(fileDiv); + }); + + createIcons({ icons }); + + const summary = document.createElement('div'); + summary.className = 'text-xs text-gray-400 mt-1'; + summary.textContent = `${files.length} file${files.length !== 1 ? 's' : ''} \u2022 ${totalPages} total pages`; + fileListEl.appendChild(summary); +} + +function formatBatesText( + template: string, + batesNum: number, + pageNum: number, + fileNum: number, + fileName: string, + padding: number +): string { + const batesStr = + padding > 0 ? String(batesNum).padStart(padding, '0') : String(batesNum); + return template + .replace(/\[BATES\]/g, batesStr) + .replace(/\[PAGE\]/g, String(pageNum)) + .replace(/\[FILE\]/g, String(fileNum)) + .replace(/\[FILENAME\]/g, fileName); +} + +function getActivePadding(): number { + const presetValue = ( + document.getElementById('style-preset') as HTMLSelectElement + ).value; + if (presetValue !== 'custom' && STYLE_PRESETS[presetValue]) { + return STYLE_PRESETS[presetValue].padding; + } + return ( + parseInt( + (document.getElementById('bates-padding') as HTMLSelectElement).value + ) || 0 + ); +} + +function updatePreview() { + const previewEl = document.getElementById('preview-content'); + if (!previewEl) return; + + const template = ( + document.getElementById('bates-template') as HTMLInputElement + ).value; + const padding = getActivePadding(); + const batesStart = + parseInt( + (document.getElementById('bates-start') as HTMLInputElement).value + ) || 1; + const fileStart = + parseInt( + (document.getElementById('file-start') as HTMLInputElement).value + ) || 1; + + const lines: string[] = []; + + if (files.length === 0) { + lines.push( + formatBatesText(template, batesStart, 1, fileStart, 'document', padding) + ); + lines.push( + formatBatesText( + template, + batesStart + 1, + 2, + fileStart, + 'document', + padding + ) + ); + } else { + let batesCounter = batesStart; + let fileCounter = fileStart; + for (const entry of files) { + const name = entry.file.name.replace(/\.pdf$/i, ''); + lines.push( + `File ${fileCounter}, Page 1: ${formatBatesText(template, batesCounter, 1, fileCounter, name, padding)}` + ); + if (entry.pageCount > 1) { + lines.push( + `File ${fileCounter}, Page 2: ${formatBatesText(template, batesCounter + 1, 2, fileCounter, name, padding)}` + ); + } + batesCounter += entry.pageCount; + fileCounter++; + } + const lastEntry = files[files.length - 1]; + const lastName = lastEntry.file.name.replace(/\.pdf$/i, ''); + const lastBates = batesCounter - 1; + lines.push('...'); + lines.push( + `File ${fileStart + files.length - 1}, Page ${lastEntry.pageCount}: ${formatBatesText(template, lastBates, lastEntry.pageCount, fileStart + files.length - 1, lastName, padding)}` + ); + } + + previewEl.textContent = lines.join('\n'); +} + +function calculatePosition( + pageWidth: number, + pageHeight: number, + xOffset: number, + yOffset: number, + textWidth: number, + fontSize: number, + position: Position +): { x: number; y: number } { + const minMargin = 8; + const maxMargin = 40; + const marginPct = 0.04; + + const hMargin = Math.max( + minMargin, + Math.min(maxMargin, pageWidth * marginPct) + ); + const vMargin = Math.max( + minMargin, + Math.min(maxMargin, pageHeight * marginPct) + ); + const safeH = Math.max(hMargin, textWidth / 2 + 3); + const safeV = Math.max(vMargin, fontSize + 3); + + let x = 0, + y = 0; + + switch (position) { + case 'bottom-center': + x = + Math.max( + safeH, + Math.min(pageWidth - safeH - textWidth, (pageWidth - textWidth) / 2) + ) + xOffset; + y = safeV + yOffset; + break; + case 'bottom-left': + x = safeH + xOffset; + y = safeV + yOffset; + break; + case 'bottom-right': + x = Math.max(safeH, pageWidth - safeH - textWidth) + xOffset; + y = safeV + yOffset; + break; + case 'top-center': + x = + Math.max( + safeH, + Math.min(pageWidth - safeH - textWidth, (pageWidth - textWidth) / 2) + ) + xOffset; + y = pageHeight - safeV - fontSize + yOffset; + break; + case 'top-left': + x = safeH + xOffset; + y = pageHeight - safeV - fontSize + yOffset; + break; + case 'top-right': + x = Math.max(safeH, pageWidth - safeH - textWidth) + xOffset; + y = pageHeight - safeV - fontSize + yOffset; + break; + } + + x = Math.max(xOffset + 3, Math.min(xOffset + pageWidth - textWidth - 3, x)); + y = Math.max(yOffset + 3, Math.min(yOffset + pageHeight - fontSize - 3, y)); + + return { x, y }; +} + +function resetState() { + files.length = 0; + const fileListEl = document.getElementById('file-list'); + if (fileListEl) fileListEl.innerHTML = ''; + document.getElementById('options-panel')?.classList.add('hidden'); + document.getElementById('file-controls')?.classList.add('hidden'); + const fileInput = document.getElementById('file-input') as HTMLInputElement; + if (fileInput) fileInput.value = ''; +} + +async function applyBatesNumbers() { + if (files.length === 0) { + showAlert('Error', 'Please upload at least one PDF file.'); + return; + } + + showLoader('Applying Bates numbers...'); + try { + const template = ( + document.getElementById('bates-template') as HTMLInputElement + ).value; + const padding = getActivePadding(); + const batesStart = + parseInt( + (document.getElementById('bates-start') as HTMLInputElement).value + ) || 1; + const fileStart = + parseInt( + (document.getElementById('file-start') as HTMLInputElement).value + ) || 1; + const position = (document.getElementById('position') as HTMLSelectElement) + .value as Position; + const fontKey = ( + document.getElementById('font-family') as HTMLSelectElement + ).value; + const fontSize = + parseInt( + (document.getElementById('font-size') as HTMLInputElement).value + ) || 10; + const colorHex = (document.getElementById('text-color') as HTMLInputElement) + .value; + const textColor = hexToRgb(colorHex); + + const fontName = FONT_MAP[fontKey] || 'Helvetica'; + const results: { name: string; bytes: Uint8Array }[] = []; + let batesCounter = batesStart; + let fileCounter = fileStart; + + for (const entry of files) { + const arrayBuffer = await entry.file.arrayBuffer(); + const pdfDoc = await PDFDocument.load(arrayBuffer); + const font = await pdfDoc.embedFont(StandardFonts[fontName]); + const pages = pdfDoc.getPages(); + const fileName = entry.file.name.replace(/\.pdf$/i, ''); + + for (let i = 0; i < pages.length; i++) { + const page = pages[i]; + const bounds = page.getCropBox() || page.getMediaBox(); + const text = formatBatesText( + template, + batesCounter, + i + 1, + fileCounter, + fileName, + padding + ); + const textWidth = font.widthOfTextAtSize(text, fontSize); + + const { x, y } = calculatePosition( + bounds.width, + bounds.height, + bounds.x || 0, + bounds.y || 0, + textWidth, + fontSize, + position + ); + + page.drawText(text, { + x, + y, + font, + size: fontSize, + color: rgb(textColor.r, textColor.g, textColor.b), + }); + + batesCounter++; + } + + fileCounter++; + const pdfBytes = await pdfDoc.save(); + results.push({ + name: `bates_${entry.file.name}`, + bytes: new Uint8Array(pdfBytes), + }); + } + + if (results.length === 1) { + downloadFile( + new Blob([new Uint8Array(results[0].bytes)], { + type: 'application/pdf', + }), + results[0].name + ); + } else { + const zip = new JSZip(); + for (const result of results) { + zip.file(result.name, result.bytes); + } + const zipBlob = await zip.generateAsync({ type: 'blob' }); + downloadFile(zipBlob, 'bates_numbered.zip'); + } + + showAlert( + 'Success', + `Bates numbers applied successfully! (${batesStart} through ${batesCounter - 1})`, + 'success', + () => { + resetState(); + } + ); + } catch (e) { + console.error(e); + showAlert('Error', 'Failed to apply Bates numbers.'); + } finally { + hideLoader(); + } +} diff --git a/src/js/logic/bookmark-pdf.ts b/src/js/logic/bookmark-pdf.ts index 2c40d0e..98bfe56 100644 --- a/src/js/logic/bookmark-pdf.ts +++ b/src/js/logic/bookmark-pdf.ts @@ -253,7 +253,7 @@ placeholder="${field.placeholder || ''}" /> ) .join('')} - ${field.name === 'color' ? '' : ''} + ${field.name === 'color' ? '' : ''}

`; } else if (field.type === 'destination') { diff --git a/src/js/logic/cbz-to-pdf-page.ts b/src/js/logic/cbz-to-pdf-page.ts index 060d40c..1573af4 100644 --- a/src/js/logic/cbz-to-pdf-page.ts +++ b/src/js/logic/cbz-to-pdf-page.ts @@ -2,336 +2,385 @@ import { showLoader, hideLoader, showAlert } from '../ui.js'; import { downloadFile, formatBytes } from '../utils/helpers.js'; import { state } from '../state.js'; import { createIcons, icons } from 'lucide'; -import { PyMuPDF } from '@bentopdf/pymupdf-wasm'; -import { getWasmBaseUrl } from '../config/wasm-cdn-config.js'; +import { isWasmAvailable, getWasmBaseUrl } from '../config/wasm-cdn-config.js'; +import { showWasmRequiredDialog } from '../utils/wasm-provider.js'; +import { loadPyMuPDF, isPyMuPDFAvailable } from '../utils/pymupdf-loader.js'; import JSZip from 'jszip'; import { PDFDocument } from 'pdf-lib'; const EXTENSIONS = ['.cbz', '.cbr']; const TOOL_NAME = 'CBZ'; -const ALL_IMAGE_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff', '.tif', '.webp', '.avif', '.jxl', '.heic', '.heif']; +const ALL_IMAGE_EXTENSIONS = [ + '.jpg', + '.jpeg', + '.png', + '.gif', + '.bmp', + '.tiff', + '.tif', + '.webp', + '.avif', + '.jxl', + '.heic', + '.heif', +]; const IMAGE_SIGNATURES = { - jpeg: [0xFF, 0xD8, 0xFF], - png: [0x89, 0x50, 0x4E, 0x47], - gif: [0x47, 0x49, 0x46], - bmp: [0x42, 0x4D], - webp: [0x52, 0x49, 0x46, 0x46], - avif: [0x00, 0x00, 0x00], + jpeg: [0xff, 0xd8, 0xff], + png: [0x89, 0x50, 0x4e, 0x47], + gif: [0x47, 0x49, 0x46], + bmp: [0x42, 0x4d], + webp: [0x52, 0x49, 0x46, 0x46], + avif: [0x00, 0x00, 0x00], }; -function matchesSignature(data: Uint8Array, signature: number[], offset = 0): boolean { - for (let i = 0; i < signature.length; i++) { - if (data[offset + i] !== signature[i]) return false; - } - return true; +function matchesSignature( + data: Uint8Array, + signature: number[], + offset = 0 +): boolean { + for (let i = 0; i < signature.length; i++) { + if (data[offset + i] !== signature[i]) return false; + } + return true; } -function detectImageFormat(data: Uint8Array): 'jpeg' | 'png' | 'gif' | 'bmp' | 'webp' | 'avif' | 'unknown' { - if (data.length < 12) return 'unknown'; - if (matchesSignature(data, IMAGE_SIGNATURES.jpeg)) return 'jpeg'; - if (matchesSignature(data, IMAGE_SIGNATURES.png)) return 'png'; - if (matchesSignature(data, IMAGE_SIGNATURES.gif)) return 'gif'; - if (matchesSignature(data, IMAGE_SIGNATURES.bmp)) return 'bmp'; - if (matchesSignature(data, IMAGE_SIGNATURES.webp) && - data[8] === 0x57 && data[9] === 0x45 && data[10] === 0x42 && data[11] === 0x50) { - return 'webp'; +function detectImageFormat( + data: Uint8Array +): 'jpeg' | 'png' | 'gif' | 'bmp' | 'webp' | 'avif' | 'unknown' { + if (data.length < 12) return 'unknown'; + if (matchesSignature(data, IMAGE_SIGNATURES.jpeg)) return 'jpeg'; + if (matchesSignature(data, IMAGE_SIGNATURES.png)) return 'png'; + if (matchesSignature(data, IMAGE_SIGNATURES.gif)) return 'gif'; + if (matchesSignature(data, IMAGE_SIGNATURES.bmp)) return 'bmp'; + if ( + matchesSignature(data, IMAGE_SIGNATURES.webp) && + data[8] === 0x57 && + data[9] === 0x45 && + data[10] === 0x42 && + data[11] === 0x50 + ) { + return 'webp'; + } + if ( + data[4] === 0x66 && + data[5] === 0x74 && + data[6] === 0x79 && + data[7] === 0x70 + ) { + const brand = String.fromCharCode(data[8], data[9], data[10], data[11]); + if ( + brand === 'avif' || + brand === 'avis' || + brand === 'mif1' || + brand === 'miaf' + ) { + return 'avif'; } - if (data[4] === 0x66 && data[5] === 0x74 && data[6] === 0x79 && data[7] === 0x70) { - const brand = String.fromCharCode(data[8], data[9], data[10], data[11]); - if (brand === 'avif' || brand === 'avis' || brand === 'mif1' || brand === 'miaf') { - return 'avif'; - } - } - return 'unknown'; + } + return 'unknown'; } function isCbzFile(filename: string): boolean { - return filename.toLowerCase().endsWith('.cbz'); + return filename.toLowerCase().endsWith('.cbz'); } -async function convertImageToPng(imageData: ArrayBuffer, filename: string): Promise { - return new Promise((resolve, reject) => { - const blob = new Blob([imageData]); - const url = URL.createObjectURL(blob); - const img = new Image(); - img.onload = () => { - const canvas = document.createElement('canvas'); - canvas.width = img.width; - canvas.height = img.height; - const ctx = canvas.getContext('2d'); - if (!ctx) { - URL.revokeObjectURL(url); - reject(new Error('Failed to get canvas context')); - return; - } - ctx.drawImage(img, 0, 0); - canvas.toBlob((pngBlob) => { - URL.revokeObjectURL(url); - if (pngBlob) { - resolve(pngBlob); - } else { - reject(new Error(`Failed to convert ${filename} to PNG`)); - } - }, 'image/png'); - }; - img.onerror = () => { - URL.revokeObjectURL(url); - reject(new Error(`Failed to load image: ${filename}`)); - }; - img.src = url; - }); +async function convertImageToPng( + imageData: ArrayBuffer, + filename: string +): Promise { + return new Promise((resolve, reject) => { + const blob = new Blob([imageData]); + const url = URL.createObjectURL(blob); + const img = new Image(); + img.onload = () => { + const canvas = document.createElement('canvas'); + canvas.width = img.width; + canvas.height = img.height; + const ctx = canvas.getContext('2d'); + if (!ctx) { + URL.revokeObjectURL(url); + reject(new Error('Failed to get canvas context')); + return; + } + ctx.drawImage(img, 0, 0); + canvas.toBlob((pngBlob) => { + URL.revokeObjectURL(url); + if (pngBlob) { + resolve(pngBlob); + } else { + reject(new Error(`Failed to convert ${filename} to PNG`)); + } + }, 'image/png'); + }; + img.onerror = () => { + URL.revokeObjectURL(url); + reject(new Error(`Failed to load image: ${filename}`)); + }; + img.src = url; + }); } async function convertCbzToPdf(file: File): Promise { - const zip = await JSZip.loadAsync(file); - const pdfDoc = await PDFDocument.create(); + const zip = await JSZip.loadAsync(file); + const pdfDoc = await PDFDocument.create(); - const imageFiles = Object.keys(zip.files) - .filter(name => { - if (zip.files[name].dir) return false; - const ext = name.toLowerCase().substring(name.lastIndexOf('.')); - return ALL_IMAGE_EXTENSIONS.includes(ext); - }) - .sort((a, b) => a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' })); + const imageFiles = Object.keys(zip.files) + .filter((name) => { + if (zip.files[name].dir) return false; + const ext = name.toLowerCase().substring(name.lastIndexOf('.')); + return ALL_IMAGE_EXTENSIONS.includes(ext); + }) + .sort((a, b) => + a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' }) + ); - for (const filename of imageFiles) { - const zipEntry = zip.files[filename]; - const imageData = await zipEntry.async('arraybuffer'); - const dataArray = new Uint8Array(imageData); - const actualFormat = detectImageFormat(dataArray); + for (const filename of imageFiles) { + const zipEntry = zip.files[filename]; + const imageData = await zipEntry.async('arraybuffer'); + const dataArray = new Uint8Array(imageData); + const actualFormat = detectImageFormat(dataArray); - let imageBytes: Uint8Array; - let embedMethod: 'png' | 'jpg'; + let imageBytes: Uint8Array; + let embedMethod: 'png' | 'jpg'; - if (actualFormat === 'jpeg') { - imageBytes = dataArray; - embedMethod = 'jpg'; - } else if (actualFormat === 'png') { - imageBytes = dataArray; - embedMethod = 'png'; - } else { - const pngBlob = await convertImageToPng(imageData, filename); - imageBytes = new Uint8Array(await pngBlob.arrayBuffer()); - embedMethod = 'png'; - } - - const image = embedMethod === 'png' - ? await pdfDoc.embedPng(imageBytes) - : await pdfDoc.embedJpg(imageBytes); - const page = pdfDoc.addPage([image.width, image.height]); - page.drawImage(image, { - x: 0, - y: 0, - width: image.width, - height: image.height, - }); + if (actualFormat === 'jpeg') { + imageBytes = dataArray; + embedMethod = 'jpg'; + } else if (actualFormat === 'png') { + imageBytes = dataArray; + embedMethod = 'png'; + } else { + const pngBlob = await convertImageToPng(imageData, filename); + imageBytes = new Uint8Array(await pngBlob.arrayBuffer()); + embedMethod = 'png'; } - const pdfBytes = await pdfDoc.save(); - return new Blob([pdfBytes.buffer as ArrayBuffer], { type: 'application/pdf' }); + const image = + embedMethod === 'png' + ? await pdfDoc.embedPng(imageBytes) + : await pdfDoc.embedJpg(imageBytes); + const page = pdfDoc.addPage([image.width, image.height]); + page.drawImage(image, { + x: 0, + y: 0, + width: image.width, + height: image.height, + }); + } + + const pdfBytes = await pdfDoc.save(); + return new Blob([pdfBytes.buffer as ArrayBuffer], { + type: 'application/pdf', + }); } async function convertCbrToPdf(file: File): Promise { - const pymupdf = new PyMuPDF(getWasmBaseUrl('pymupdf')); - await pymupdf.load(); - return await pymupdf.convertToPdf(file, { filetype: 'cbz' }); + const pymupdf = await loadPyMuPDF(); + return await (pymupdf as any).convertToPdf(file, { filetype: 'cbz' }); } document.addEventListener('DOMContentLoaded', () => { - const fileInput = document.getElementById('file-input') as HTMLInputElement; - const dropZone = document.getElementById('drop-zone'); - const processBtn = document.getElementById('process-btn'); - const fileDisplayArea = document.getElementById('file-display-area'); - const fileControls = document.getElementById('file-controls'); - const addMoreBtn = document.getElementById('add-more-btn'); - const clearFilesBtn = document.getElementById('clear-files-btn'); - const backBtn = document.getElementById('back-to-tools'); + const fileInput = document.getElementById('file-input') as HTMLInputElement; + const dropZone = document.getElementById('drop-zone'); + const processBtn = document.getElementById('process-btn'); + const fileDisplayArea = document.getElementById('file-display-area'); + const fileControls = document.getElementById('file-controls'); + const addMoreBtn = document.getElementById('add-more-btn'); + const clearFilesBtn = document.getElementById('clear-files-btn'); + const backBtn = document.getElementById('back-to-tools'); - if (backBtn) { - backBtn.addEventListener('click', () => { - window.location.href = import.meta.env.BASE_URL; - }); + if (backBtn) { + backBtn.addEventListener('click', () => { + window.location.href = import.meta.env.BASE_URL; + }); + } + + const updateUI = async () => { + if (!fileDisplayArea || !processBtn || !fileControls) return; + + if (state.files.length > 0) { + fileDisplayArea.innerHTML = ''; + + for (let index = 0; index < state.files.length; index++) { + const file = state.files[index]; + const fileDiv = document.createElement('div'); + fileDiv.className = + 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm'; + + const infoContainer = document.createElement('div'); + infoContainer.className = 'flex flex-col overflow-hidden'; + + const nameSpan = document.createElement('div'); + nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1'; + nameSpan.textContent = file.name; + + const metaSpan = document.createElement('div'); + metaSpan.className = 'text-xs text-gray-400'; + metaSpan.textContent = formatBytes(file.size); + + 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 = () => { + state.files = state.files.filter((_, i) => i !== index); + updateUI(); + }; + + fileDiv.append(infoContainer, removeBtn); + fileDisplayArea.appendChild(fileDiv); + } + + createIcons({ icons }); + fileControls.classList.remove('hidden'); + processBtn.classList.remove('hidden'); + (processBtn as HTMLButtonElement).disabled = false; + } else { + fileDisplayArea.innerHTML = ''; + fileControls.classList.add('hidden'); + processBtn.classList.add('hidden'); + (processBtn as HTMLButtonElement).disabled = true; } + }; - const updateUI = async () => { - if (!fileDisplayArea || !processBtn || !fileControls) return; + const resetState = () => { + state.files = []; + state.pdfDoc = null; + updateUI(); + }; - if (state.files.length > 0) { - fileDisplayArea.innerHTML = ''; + const convertToPdf = async () => { + try { + if (state.files.length === 0) { + showAlert('No Files', `Please select at least one ${TOOL_NAME} file.`); + return; + } - for (let index = 0; index < state.files.length; index++) { - const file = state.files[index]; - const fileDiv = document.createElement('div'); - fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm'; + if (state.files.length === 1) { + const originalFile = state.files[0]; + showLoader(`Converting ${originalFile.name}...`); - const infoContainer = document.createElement('div'); - infoContainer.className = 'flex flex-col overflow-hidden'; - - const nameSpan = document.createElement('div'); - nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1'; - nameSpan.textContent = file.name; - - const metaSpan = document.createElement('div'); - metaSpan.className = 'text-xs text-gray-400'; - metaSpan.textContent = formatBytes(file.size); - - 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 = () => { - state.files = state.files.filter((_, i) => i !== index); - updateUI(); - }; - - fileDiv.append(infoContainer, removeBtn); - fileDisplayArea.appendChild(fileDiv); - } - - createIcons({ icons }); - fileControls.classList.remove('hidden'); - processBtn.classList.remove('hidden'); - (processBtn as HTMLButtonElement).disabled = false; + let pdfBlob: Blob; + if (isCbzFile(originalFile.name)) { + pdfBlob = await convertCbzToPdf(originalFile); } else { - fileDisplayArea.innerHTML = ''; - fileControls.classList.add('hidden'); - processBtn.classList.add('hidden'); - (processBtn as HTMLButtonElement).disabled = true; + pdfBlob = await convertCbrToPdf(originalFile); } - }; - const resetState = () => { - state.files = []; - state.pdfDoc = null; - updateUI(); - }; + const fileName = originalFile.name.replace(/\.[^.]+$/, '') + '.pdf'; + downloadFile(pdfBlob, fileName); + hideLoader(); - const convertToPdf = async () => { - try { - if (state.files.length === 0) { - showAlert('No Files', `Please select at least one ${TOOL_NAME} file.`); - return; - } + showAlert( + 'Conversion Complete', + `Successfully converted ${originalFile.name} to PDF.`, + 'success', + () => resetState() + ); + } else { + showLoader('Converting files...'); + const outputZip = new JSZip(); - if (state.files.length === 1) { - const originalFile = state.files[0]; - showLoader(`Converting ${originalFile.name}...`); + for (let i = 0; i < state.files.length; i++) { + const file = state.files[i]; + showLoader( + `Converting ${i + 1}/${state.files.length}: ${file.name}...` + ); - let pdfBlob: Blob; - if (isCbzFile(originalFile.name)) { - pdfBlob = await convertCbzToPdf(originalFile); - } else { - pdfBlob = await convertCbrToPdf(originalFile); - } + let pdfBlob: Blob; + if (isCbzFile(file.name)) { + pdfBlob = await convertCbzToPdf(file); + } else { + pdfBlob = await convertCbrToPdf(file); + } - const fileName = originalFile.name.replace(/\.[^.]+$/, '') + '.pdf'; - downloadFile(pdfBlob, fileName); - hideLoader(); - - showAlert( - 'Conversion Complete', - `Successfully converted ${originalFile.name} to PDF.`, - 'success', - () => resetState() - ); - } else { - showLoader('Converting files...'); - const outputZip = new JSZip(); - - for (let i = 0; i < state.files.length; i++) { - const file = state.files[i]; - showLoader(`Converting ${i + 1}/${state.files.length}: ${file.name}...`); - - let pdfBlob: Blob; - if (isCbzFile(file.name)) { - pdfBlob = await convertCbzToPdf(file); - } else { - pdfBlob = await convertCbrToPdf(file); - } - - const baseName = file.name.replace(/\.[^.]+$/, ''); - const pdfBuffer = await pdfBlob.arrayBuffer(); - outputZip.file(`${baseName}.pdf`, pdfBuffer); - } - - const zipBlob = await outputZip.generateAsync({ type: 'blob' }); - downloadFile(zipBlob, 'comic-converted.zip'); - - hideLoader(); - - showAlert( - 'Conversion Complete', - `Successfully converted ${state.files.length} ${TOOL_NAME} file(s) to PDF.`, - 'success', - () => resetState() - ); - } - } catch (e: any) { - console.error(`[${TOOL_NAME}2PDF] ERROR:`, e); - hideLoader(); - showAlert('Error', `An error occurred during conversion. Error: ${e.message}`); + const baseName = file.name.replace(/\.[^.]+$/, ''); + const pdfBuffer = await pdfBlob.arrayBuffer(); + outputZip.file(`${baseName}.pdf`, pdfBuffer); } - }; - const handleFileSelect = (files: FileList | null) => { - if (files && files.length > 0) { - state.files = [...state.files, ...Array.from(files)]; - updateUI(); + const zipBlob = await outputZip.generateAsync({ type: 'blob' }); + downloadFile(zipBlob, 'comic-converted.zip'); + + hideLoader(); + + showAlert( + 'Conversion Complete', + `Successfully converted ${state.files.length} ${TOOL_NAME} file(s) to PDF.`, + 'success', + () => resetState() + ); + } + } catch (e: any) { + console.error(`[${TOOL_NAME}2PDF] ERROR:`, e); + hideLoader(); + showAlert( + 'Error', + `An error occurred during conversion. Error: ${e.message}` + ); + } + }; + + const handleFileSelect = (files: FileList | null) => { + if (files && files.length > 0) { + state.files = [...state.files, ...Array.from(files)]; + updateUI(); + } + }; + + if (fileInput && dropZone) { + fileInput.addEventListener('change', (e) => { + handleFileSelect((e.target as HTMLInputElement).files); + }); + + dropZone.addEventListener('dragover', (e) => { + e.preventDefault(); + dropZone.classList.add('bg-gray-700'); + }); + + dropZone.addEventListener('dragleave', (e) => { + e.preventDefault(); + dropZone.classList.remove('bg-gray-700'); + }); + + dropZone.addEventListener('drop', (e) => { + e.preventDefault(); + dropZone.classList.remove('bg-gray-700'); + const files = e.dataTransfer?.files; + if (files && files.length > 0) { + const validFiles = Array.from(files).filter((f) => { + const name = f.name.toLowerCase(); + return EXTENSIONS.some((ext) => name.endsWith(ext)); + }); + if (validFiles.length > 0) { + const dataTransfer = new DataTransfer(); + validFiles.forEach((f) => dataTransfer.items.add(f)); + handleFileSelect(dataTransfer.files); } - }; + } + }); - if (fileInput && dropZone) { - fileInput.addEventListener('change', (e) => { - handleFileSelect((e.target as HTMLInputElement).files); - }); + fileInput.addEventListener('click', () => { + fileInput.value = ''; + }); + } - dropZone.addEventListener('dragover', (e) => { - e.preventDefault(); - dropZone.classList.add('bg-gray-700'); - }); + if (addMoreBtn) { + addMoreBtn.addEventListener('click', () => { + fileInput.click(); + }); + } - dropZone.addEventListener('dragleave', (e) => { - e.preventDefault(); - dropZone.classList.remove('bg-gray-700'); - }); + if (clearFilesBtn) { + clearFilesBtn.addEventListener('click', () => { + resetState(); + }); + } - dropZone.addEventListener('drop', (e) => { - e.preventDefault(); - dropZone.classList.remove('bg-gray-700'); - const files = e.dataTransfer?.files; - if (files && files.length > 0) { - const validFiles = Array.from(files).filter(f => { - const name = f.name.toLowerCase(); - return EXTENSIONS.some(ext => name.endsWith(ext)); - }); - if (validFiles.length > 0) { - const dataTransfer = new DataTransfer(); - validFiles.forEach(f => dataTransfer.items.add(f)); - handleFileSelect(dataTransfer.files); - } - } - }); - - fileInput.addEventListener('click', () => { - fileInput.value = ''; - }); - } - - if (addMoreBtn) { - addMoreBtn.addEventListener('click', () => { - fileInput.click(); - }); - } - - if (clearFilesBtn) { - clearFilesBtn.addEventListener('click', () => { - resetState(); - }); - } - - if (processBtn) { - processBtn.addEventListener('click', convertToPdf); - } + if (processBtn) { + processBtn.addEventListener('click', convertToPdf); + } }); diff --git a/src/js/logic/compare-pdfs-page.ts b/src/js/logic/compare-pdfs-page.ts index 93cd7dc..2e267aa 100644 --- a/src/js/logic/compare-pdfs-page.ts +++ b/src/js/logic/compare-pdfs-page.ts @@ -1,299 +1,989 @@ -import { showLoader, hideLoader, showAlert } from '../ui.js'; -import { getPDFDocument } from '../utils/helpers.js'; +import { showLoader, hideLoader, showAlert } from '../ui.ts'; +import { getPDFDocument } from '../utils/helpers.ts'; import { icons, createIcons } from 'lucide'; import * as pdfjsLib from 'pdfjs-dist'; import { CompareState } from '@/types'; +import type { + CompareFilterType, + ComparePageResult, + CompareTextChange, + CompareCategoryFilterState, +} from '../compare/types.ts'; +import { extractDocumentSignatures } from '../compare/engine/page-signatures.ts'; +import { pairPagesAsync } from '../compare/worker-api.ts'; +import type { + ComparePdfExportMode, + CompareCaches, + CompareRenderContext, +} from '../compare/types.ts'; +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 { + getElement, + computeComparisonForPair, + getComparisonCacheKey, +} from './compare-render.ts'; -pdfjsLib.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).toString(); +pdfjsLib.GlobalWorkerOptions.workerSrc = new URL( + 'pdfjs-dist/build/pdf.worker.min.mjs', + import.meta.url +).toString(); const pageState: CompareState = { - pdfDoc1: null, - pdfDoc2: null, - currentPage: 1, - viewMode: 'overlay', - isSyncScroll: true, + pdfDoc1: null, + pdfDoc2: null, + currentPage: 1, + viewMode: 'side-by-side', + isSyncScroll: true, + currentComparison: null, + activeChangeIndex: 0, + pagePairs: [], + activeFilter: 'all', + categoryFilter: { + text: true, + image: true, + 'header-footer': true, + annotation: true, + formatting: true, + background: true, + }, + changeSearchQuery: '', + useOcr: true, + ocrLanguage: 'eng', + zoomLevel: 1.0, }; -async function renderPage( - pdfDoc: pdfjsLib.PDFDocumentProxy, - pageNum: number, - canvas: HTMLCanvasElement, - container: HTMLElement -) { - const page = await pdfDoc.getPage(pageNum); +const caches: CompareCaches = { + pageModelCache: new LRUCache(COMPARE_CACHE_MAX_SIZE), + comparisonCache: new LRUCache(COMPARE_CACHE_MAX_SIZE), + comparisonResultsCache: new LRUCache(COMPARE_CACHE_MAX_SIZE), + ocrModelCache: new LRUCache(COMPARE_CACHE_MAX_SIZE), +}; +const documentNames = { + left: 'first.pdf', + right: 'second.pdf', +}; - const containerWidth = container.clientWidth - 2; - const viewport = page.getViewport({ scale: 1.0 }); - const scale = containerWidth / viewport.width; - const scaledViewport = page.getViewport({ scale: scale }); +let renderGeneration = 0; - canvas.width = scaledViewport.width; - canvas.height = scaledViewport.height; +function getActivePair() { + return pageState.pagePairs[pageState.currentPage - 1] || null; +} - await page.render({ - canvasContext: canvas.getContext('2d')!, - viewport: scaledViewport, - canvas - }).promise; +function getRenderContext(): CompareRenderContext { + return { + useOcr: pageState.useOcr, + ocrLanguage: pageState.ocrLanguage, + viewMode: pageState.viewMode, + zoomLevel: pageState.zoomLevel, + showLoader, + }; +} + +function getVisibleChanges(result: ComparePageResult | null) { + if (!result) return []; + + const filteredByType = + pageState.activeFilter === 'all' + ? result.changes + : result.changes.filter((change) => { + if (pageState.activeFilter === 'removed') { + return change.type === 'removed' || change.type === 'page-removed'; + } + if (pageState.activeFilter === 'added') { + return change.type === 'added' || change.type === 'page-added'; + } + return change.type === pageState.activeFilter; + }); + + const filteredByCategory = filteredByType.filter( + (change) => pageState.categoryFilter[change.category] + ); + + const searchQuery = pageState.changeSearchQuery.trim().toLowerCase(); + if (!searchQuery) { + return filteredByCategory; + } + + return filteredByCategory.filter((change) => { + const searchableText = [ + change.description, + change.beforeText, + change.afterText, + ] + .join(' ') + .toLowerCase(); + return searchableText.includes(searchQuery); + }); +} + +function updateFilterButtons() { + const pills: Array<{ id: string; filter: CompareFilterType }> = [ + { id: 'filter-modified', filter: 'modified' }, + { id: 'filter-added', filter: 'added' }, + { id: 'filter-removed', filter: 'removed' }, + { id: 'filter-moved', filter: 'moved' }, + { id: 'filter-style-changed', filter: 'style-changed' }, + ]; + + pills.forEach(({ id, filter }) => { + const button = getElement(id); + if (!button) return; + button.classList.toggle('active', pageState.activeFilter === filter); + }); +} + +function updateSummary() { + const comparison = pageState.currentComparison; + const addedCount = getElement('summary-added-count'); + const removedCount = getElement('summary-removed-count'); + const modifiedCount = getElement('summary-modified-count'); + const movedCount = getElement('summary-moved-count'); + const styleChangedCount = getElement( + 'summary-style-changed-count' + ); + const panelLabel1 = getElement('compare-panel-label-1'); + const panelLabel2 = getElement('compare-panel-label-2'); + + if (panelLabel1) panelLabel1.textContent = documentNames.left; + if (panelLabel2) panelLabel2.textContent = documentNames.right; + + if (!comparison) { + if (addedCount) addedCount.textContent = '0'; + if (removedCount) removedCount.textContent = '0'; + if (modifiedCount) modifiedCount.textContent = '0'; + if (movedCount) movedCount.textContent = '0'; + if (styleChangedCount) styleChangedCount.textContent = '0'; + updateCategoryPills(null); + return; + } + + if (addedCount) addedCount.textContent = comparison.summary.added.toString(); + if (removedCount) + removedCount.textContent = comparison.summary.removed.toString(); + if (modifiedCount) + modifiedCount.textContent = comparison.summary.modified.toString(); + if (movedCount) movedCount.textContent = comparison.summary.moved.toString(); + if (styleChangedCount) + styleChangedCount.textContent = comparison.summary.styleChanged.toString(); + + updateCategoryPills(comparison); +} + +function updateCategoryPills(comparison: ComparePageResult | null) { + const categoryKeys: Array = [ + 'text', + 'image', + 'header-footer', + 'annotation', + 'formatting', + 'background', + ]; + + const summary = comparison?.categorySummary; + + for (const key of categoryKeys) { + const countEl = getElement(`category-count-${key}`); + const pill = getElement(`category-${key}`); + if (countEl) countEl.textContent = summary ? summary[key].toString() : '0'; + if (pill) { + pill.classList.toggle('active', pageState.categoryFilter[key]); + pill.classList.toggle('disabled', !pageState.categoryFilter[key]); + } + } +} + +function renderHighlights() { + const highlightLayer1 = getElement('highlights-1'); + const highlightLayer2 = getElement('highlights-2'); + + if (!highlightLayer1 || !highlightLayer2) return; + + highlightLayer1.innerHTML = ''; + highlightLayer2.innerHTML = ''; + + const comparison = pageState.currentComparison; + if (!comparison) return; + + getVisibleChanges(comparison).forEach((change, index) => { + const activeClass = index === pageState.activeChangeIndex ? ' active' : ''; + change.beforeRects.forEach((rect) => { + const marker = document.createElement('div'); + marker.className = `compare-highlight ${change.type}${activeClass}`; + marker.style.left = `${rect.x}px`; + marker.style.top = `${rect.y}px`; + marker.style.width = `${rect.width}px`; + marker.style.height = `${rect.height}px`; + highlightLayer1.appendChild(marker); + }); + + change.afterRects.forEach((rect) => { + const marker = document.createElement('div'); + marker.className = `compare-highlight ${change.type}${activeClass}`; + marker.style.left = `${rect.x}px`; + marker.style.top = `${rect.y}px`; + marker.style.width = `${rect.width}px`; + marker.style.height = `${rect.height}px`; + highlightLayer2.appendChild(marker); + }); + }); +} + +function scrollToChange(change: CompareTextChange) { + const panel1 = getElement('panel-1'); + const panel2 = getElement('panel-2'); + const firstBefore = change.beforeRects[0]; + const firstAfter = change.afterRects[0]; + + if (panel1 && firstBefore) { + panel1.scrollTo({ + top: Math.max(firstBefore.y - 40, 0), + behavior: 'smooth', + }); + } + + if (panel2 && firstAfter) { + panel2.scrollTo({ + top: Math.max(firstAfter.y - 40, 0), + behavior: 'smooth', + }); + } +} + +function renderChangeList() { + const comparison = pageState.currentComparison; + const list = getElement('compare-change-list'); + const emptyState = getElement('change-list-empty'); + const prevChangeBtn = getElement('prev-change-btn'); + const nextChangeBtn = getElement('next-change-btn'); + const exportDropdownBtn = getElement( + 'export-dropdown-btn' + ); + + if ( + !list || + !emptyState || + !prevChangeBtn || + !nextChangeBtn || + !exportDropdownBtn + ) + return; + + list.innerHTML = ''; + const visibleChanges = getVisibleChanges(comparison); + + if (!comparison || visibleChanges.length === 0) { + emptyState.textContent = + comparison?.status === 'match' + ? 'No differences detected on this page.' + : 'No changes match the current filter.'; + emptyState.classList.remove('hidden'); + list.classList.add('hidden'); + prevChangeBtn.disabled = true; + nextChangeBtn.disabled = true; + exportDropdownBtn.disabled = pageState.pagePairs.length === 0; + return; + } + + emptyState.classList.add('hidden'); + list.classList.remove('hidden'); + + const typeLabels: Record = { + added: 'Added', + removed: 'Deleted', + modified: 'Modified', + moved: 'Moved', + 'style-changed': 'Style Changed', + 'page-added': 'Page Added', + 'page-removed': 'Page Removed', + }; + + const grouped = new Map< + string, + Array<{ change: CompareTextChange; index: number }> + >(); + visibleChanges.forEach((change, index) => { + const key = change.type; + if (!grouped.has(key)) grouped.set(key, []); + grouped.get(key)!.push({ change, index }); + }); + + for (const [type, entries] of grouped) { + const header = document.createElement('div'); + header.className = 'compare-section-header'; + header.innerHTML = ` + + ${entries.length} + + `; + list.appendChild(header); + + const arrowSvg = + ''; + + for (const { change, index } of entries) { + const item = document.createElement('div'); + item.className = `compare-change-item${index === pageState.activeChangeIndex ? ' active' : ''}`; + const safeDesc = change.description + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/\n/g, '
') + .replace(/→/g, arrowSvg); + item.innerHTML = `
${safeDesc}
`; + + item.addEventListener('click', function () { + pageState.activeChangeIndex = index; + renderComparisonUI(); + scrollToChange(change); + }); + + list.appendChild(item); + } + } + + prevChangeBtn.disabled = false; + nextChangeBtn.disabled = false; + exportDropdownBtn.disabled = pageState.pagePairs.length === 0; +} + +function renderComparisonUI() { + updateFilterButtons(); + renderHighlights(); + renderChangeList(); + updateSummary(); +} + +async function buildPagePairs() { + if (!pageState.pdfDoc1 || !pageState.pdfDoc2) return; + + showLoader('Building page pairing model...', 0); + + const leftSignatures = await extractDocumentSignatures( + pageState.pdfDoc1, + function (pageNumber, totalPages) { + showLoader( + `Indexing PDF 1 page ${pageNumber} of ${totalPages}...`, + (pageNumber / Math.max(totalPages * 2, 1)) * 100 + ); + } + ); + const rightSignatures = await extractDocumentSignatures( + pageState.pdfDoc2, + function (pageNumber, totalPages) { + showLoader( + `Indexing PDF 2 page ${pageNumber} of ${totalPages}...`, + 50 + (pageNumber / Math.max(totalPages * 2, 1)) * 100 + ); + } + ); + + pageState.pagePairs = await pairPagesAsync(leftSignatures, rightSignatures); + pageState.currentPage = 1; +} + +async function buildReportResults() { + const results: ComparePageResult[] = []; + const ctx = getRenderContext(); + + for (const pair of pageState.pagePairs) { + const cached = caches.comparisonResultsCache.get(pair.pairIndex); + if (cached) { + results.push(cached); + continue; + } + + const cacheKey = getComparisonCacheKey(pair, pageState.useOcr); + const cachedResult = caches.comparisonCache.get(cacheKey); + if (cachedResult) { + results.push(cachedResult); + continue; + } + + const comparison = await computeComparisonForPair( + pageState.pdfDoc1, + pageState.pdfDoc2, + pair, + caches, + ctx + ); + caches.comparisonCache.set(cacheKey, comparison); + caches.comparisonResultsCache.set(pair.pairIndex, comparison); + results.push(comparison); + } + + return results; } async function renderBothPages() { - if (!pageState.pdfDoc1 || !pageState.pdfDoc2) return; + if (!pageState.pdfDoc1 || !pageState.pdfDoc2) return; - showLoader(`Loading page ${pageState.currentPage}...`); + const pair = getActivePair(); + if (!pair) return; - const canvas1 = document.getElementById('canvas-compare-1') as HTMLCanvasElement; - const canvas2 = document.getElementById('canvas-compare-2') as HTMLCanvasElement; - const panel1 = document.getElementById('panel-1') as HTMLElement; - const panel2 = document.getElementById('panel-2') as HTMLElement; - const wrapper = document.getElementById('compare-viewer-wrapper') as HTMLElement; + const gen = ++renderGeneration; - const container1 = pageState.viewMode === 'overlay' ? wrapper : panel1; - const container2 = pageState.viewMode === 'overlay' ? wrapper : panel2; + showLoader( + `Loading comparison ${pageState.currentPage} of ${pageState.pagePairs.length}...` + ); - await Promise.all([ - renderPage( - pageState.pdfDoc1, - Math.min(pageState.currentPage, pageState.pdfDoc1.numPages), - canvas1, - container1 - ), - renderPage( - pageState.pdfDoc2, - Math.min(pageState.currentPage, pageState.pdfDoc2.numPages), - canvas2, - container2 - ), - ]); + const canvas1 = getElement( + 'canvas-compare-1' + ) as HTMLCanvasElement; + const canvas2 = getElement( + 'canvas-compare-2' + ) as HTMLCanvasElement; + const panel1 = getElement('panel-1') as HTMLElement; + const panel2 = getElement('panel-2') as HTMLElement; - updateNavControls(); - hideLoader(); + const container1 = panel1; + const container2 = pageState.viewMode === 'overlay' ? panel1 : panel2; + + const ctx = getRenderContext(); + + const comparison = await computeComparisonForPair( + pageState.pdfDoc1, + pageState.pdfDoc2, + pair, + caches, + ctx, + { + renderTargets: { + left: { + canvas: canvas1, + container: container1, + placeholderId: 'placeholder-1', + }, + right: { + canvas: canvas2, + container: container2, + placeholderId: 'placeholder-2', + }, + }, + } + ); + + if (gen !== renderGeneration) return; + + pageState.currentComparison = comparison; + pageState.activeChangeIndex = 0; + + updateNavControls(); + renderComparisonUI(); + hideLoader(); } function updateNavControls() { - const maxPages = Math.max( - pageState.pdfDoc1?.numPages || 0, - pageState.pdfDoc2?.numPages || 0 + const totalPairs = + pageState.pagePairs.length || + Math.max( + pageState.pdfDoc1?.numPages || 0, + pageState.pdfDoc2?.numPages || 0 ); - const currentDisplay = document.getElementById('current-page-display-compare'); - const totalDisplay = document.getElementById('total-pages-display-compare'); - const prevBtn = document.getElementById('prev-page-compare') as HTMLButtonElement; - const nextBtn = document.getElementById('next-page-compare') as HTMLButtonElement; + const currentDisplay = document.getElementById( + 'current-page-display-compare' + ); + const totalDisplay = document.getElementById('total-pages-display-compare'); + const prevBtn = document.getElementById( + 'prev-page-compare' + ) as HTMLButtonElement; + const nextBtn = document.getElementById( + 'next-page-compare' + ) as HTMLButtonElement; - if (currentDisplay) currentDisplay.textContent = pageState.currentPage.toString(); - if (totalDisplay) totalDisplay.textContent = maxPages.toString(); - if (prevBtn) prevBtn.disabled = pageState.currentPage <= 1; - if (nextBtn) nextBtn.disabled = pageState.currentPage >= maxPages; + if (currentDisplay) + currentDisplay.textContent = pageState.currentPage.toString(); + if (totalDisplay) totalDisplay.textContent = totalPairs.toString(); + if (prevBtn) prevBtn.disabled = pageState.currentPage <= 1; + if (nextBtn) nextBtn.disabled = pageState.currentPage >= totalPairs; } function setViewMode(mode: 'overlay' | 'side-by-side') { - pageState.viewMode = mode; - const wrapper = document.getElementById('compare-viewer-wrapper'); - const overlayControls = document.getElementById('overlay-controls'); - const sideControls = document.getElementById('side-by-side-controls'); - const btnOverlay = document.getElementById('view-mode-overlay'); - const btnSide = document.getElementById('view-mode-side'); - const canvas2 = document.getElementById('canvas-compare-2') as HTMLCanvasElement; - const opacitySlider = document.getElementById('opacity-slider') as HTMLInputElement; + pageState.viewMode = mode; + const wrapper = document.getElementById('compare-viewer-wrapper'); + const overlayControls = document.getElementById('overlay-controls'); + const sideControls = document.getElementById('side-by-side-controls'); + const btnOverlay = document.getElementById('view-mode-overlay'); + const btnSide = document.getElementById('view-mode-side'); + const canvas2 = getElement( + 'canvas-compare-2' + ) as HTMLCanvasElement; + const opacitySlider = getElement( + 'opacity-slider' + ) as HTMLInputElement; - if (mode === 'overlay') { - if (wrapper) wrapper.className = 'compare-viewer-wrapper overlay-mode bg-gray-900 rounded-lg border border-gray-700 min-h-[400px] relative'; - if (overlayControls) overlayControls.classList.remove('hidden'); - if (sideControls) sideControls.classList.add('hidden'); - if (btnOverlay) { - btnOverlay.classList.add('bg-indigo-600'); - btnOverlay.classList.remove('bg-gray-700'); - } - if (btnSide) { - btnSide.classList.remove('bg-indigo-600'); - btnSide.classList.add('bg-gray-700'); - } - if (canvas2 && opacitySlider) canvas2.style.opacity = opacitySlider.value; - } else { - if (wrapper) wrapper.className = 'compare-viewer-wrapper side-by-side-mode bg-gray-900 rounded-lg border border-gray-700 min-h-[400px]'; - if (overlayControls) overlayControls.classList.add('hidden'); - if (sideControls) sideControls.classList.remove('hidden'); - if (btnOverlay) { - btnOverlay.classList.remove('bg-indigo-600'); - btnOverlay.classList.add('bg-gray-700'); - } - if (btnSide) { - btnSide.classList.add('bg-indigo-600'); - btnSide.classList.remove('bg-gray-700'); - } - if (canvas2) canvas2.style.opacity = '1'; + if (mode === 'overlay') { + if (wrapper) + wrapper.className = + 'compare-viewer-wrapper overlay-mode border border-slate-200'; + if (overlayControls) overlayControls.classList.remove('hidden'); + if (sideControls) sideControls.classList.add('hidden'); + if (btnOverlay) { + btnOverlay.classList.add('bg-indigo-600'); + btnOverlay.classList.remove('bg-gray-700'); } + if (btnSide) { + btnSide.classList.remove('bg-indigo-600'); + btnSide.classList.add('bg-gray-700'); + } + if (canvas2 && opacitySlider) { + const panel2 = getElement('panel-2'); + if (panel2) panel2.style.opacity = opacitySlider.value; + } + pageState.isSyncScroll = true; + } else { + if (wrapper) + wrapper.className = + 'compare-viewer-wrapper side-by-side-mode border border-slate-200'; + if (overlayControls) overlayControls.classList.add('hidden'); + if (sideControls) sideControls.classList.remove('hidden'); + if (btnOverlay) { + btnOverlay.classList.remove('bg-indigo-600'); + btnOverlay.classList.add('bg-gray-700'); + } + if (btnSide) { + btnSide.classList.add('bg-indigo-600'); + btnSide.classList.remove('bg-gray-700'); + } + if (canvas2) canvas2.style.opacity = '1'; + const panel2 = getElement('panel-2'); + if (panel2) panel2.style.opacity = '1'; + } + + const p1 = getElement('panel-1'); + const p2 = getElement('panel-2'); + if (mode === 'overlay' && p1 && p2) { + p2.scrollTop = p1.scrollTop; + p2.scrollLeft = p1.scrollLeft; + } + + if (pageState.pdfDoc1 && pageState.pdfDoc2) { renderBothPages(); + } } -async function handleFileInput(inputId: string, docKey: 'pdfDoc1' | 'pdfDoc2', displayId: string) { - const fileInput = document.getElementById(inputId) as HTMLInputElement; - const dropZone = document.getElementById(`drop-zone-${inputId.slice(-1)}`); +async function handleFileInput( + inputId: string, + docKey: 'pdfDoc1' | 'pdfDoc2', + displayId: string +) { + const fileInput = document.getElementById(inputId) as HTMLInputElement; + const dropZone = document.getElementById(`drop-zone-${inputId.slice(-1)}`); - async function handleFile(file: File) { - if (!file || file.type !== 'application/pdf') { - showAlert('Invalid File', 'Please select a valid PDF file.'); - return; - } - - const displayDiv = document.getElementById(displayId); - if (displayDiv) { - displayDiv.innerHTML = ''; - - const icon = document.createElement('i'); - icon.setAttribute('data-lucide', 'check-circle'); - icon.className = 'w-10 h-10 mb-3 text-green-500'; - - const p = document.createElement('p'); - p.className = 'text-sm text-gray-300 truncate'; - p.textContent = file.name; - - displayDiv.append(icon, p); - createIcons({ icons }); - } - - try { - showLoader(`Loading ${file.name}...`); - const arrayBuffer = await file.arrayBuffer(); - pageState[docKey] = await getPDFDocument({ data: arrayBuffer }).promise; - - if (pageState.pdfDoc1 && pageState.pdfDoc2) { - const compareViewer = document.getElementById('compare-viewer'); - if (compareViewer) compareViewer.classList.remove('hidden'); - pageState.currentPage = 1; - await renderBothPages(); - } - } catch (e) { - showAlert('Error', 'Could not load PDF. It may be corrupt or password-protected.'); - console.error(e); - } finally { - hideLoader(); - } + async function handleFile(file: File) { + if (!file || file.type !== 'application/pdf') { + showAlert('Invalid File', 'Please select a valid PDF file.'); + return; } - if (fileInput) { - fileInput.addEventListener('change', function (e) { - const files = (e.target as HTMLInputElement).files; - if (files && files[0]) handleFile(files[0]); - }); + const displayDiv = document.getElementById(displayId); + if (displayDiv) { + displayDiv.innerHTML = ''; + + const icon = document.createElement('i'); + icon.setAttribute('data-lucide', 'check-circle'); + icon.className = 'w-10 h-10 mb-3 text-green-500'; + + const p = document.createElement('p'); + p.className = 'text-sm text-gray-300 truncate'; + p.textContent = file.name; + + if (docKey === 'pdfDoc1') documentNames.left = file.name; + if (docKey === 'pdfDoc2') documentNames.right = file.name; + + const panelLabel1 = getElement('compare-panel-label-1'); + const panelLabel2 = getElement('compare-panel-label-2'); + if (docKey === 'pdfDoc1' && panelLabel1) + panelLabel1.textContent = file.name; + if (docKey === 'pdfDoc2' && panelLabel2) + panelLabel2.textContent = file.name; + + displayDiv.append(icon, p); + createIcons({ icons }); } - if (dropZone) { - dropZone.addEventListener('dragover', function (e) { - e.preventDefault(); - }); - dropZone.addEventListener('drop', function (e) { - e.preventDefault(); - const files = e.dataTransfer?.files; - if (files && files[0]) handleFile(files[0]); - }); + try { + showLoader(`Loading ${file.name}...`); + const arrayBuffer = await file.arrayBuffer(); + pageState[docKey] = await getPDFDocument({ data: arrayBuffer }).promise; + caches.pageModelCache.clear(); + caches.comparisonCache.clear(); + caches.comparisonResultsCache.clear(); + pageState.changeSearchQuery = ''; + + const searchInput = getElement('compare-search-input'); + if (searchInput) { + searchInput.value = ''; + } + + if (pageState.pdfDoc1 && pageState.pdfDoc2) { + const compareViewer = document.getElementById('compare-viewer'); + if (compareViewer) compareViewer.classList.remove('hidden'); + await buildPagePairs(); + await renderBothPages(); + } + } catch (e) { + showAlert( + 'Error', + 'Could not load PDF. It may be corrupt or password-protected.' + ); + console.error(e); + } finally { + hideLoader(); } + } + + if (fileInput) { + fileInput.addEventListener('change', function (e) { + const files = (e.target as HTMLInputElement).files; + if (files && files[0]) handleFile(files[0]); + }); + } + + if (dropZone) { + dropZone.addEventListener('dragover', function (e) { + e.preventDefault(); + }); + dropZone.addEventListener('drop', function (e) { + e.preventDefault(); + const files = e.dataTransfer?.files; + if (files && files[0]) handleFile(files[0]); + }); + } } document.addEventListener('DOMContentLoaded', function () { - const backBtn = document.getElementById('back-to-tools'); + const backBtn = getElement('back-to-tools'); - if (backBtn) { - backBtn.addEventListener('click', function () { - window.location.href = import.meta.env.BASE_URL; - }); + if (backBtn) { + backBtn.addEventListener('click', function () { + window.location.href = import.meta.env.BASE_URL; + }); + } + + handleFileInput('file-input-1', 'pdfDoc1', 'file-display-1'); + handleFileInput('file-input-2', 'pdfDoc2', 'file-display-2'); + + const prevBtn = getElement('prev-page-compare'); + const nextBtn = getElement('next-page-compare'); + + if (prevBtn) { + prevBtn.addEventListener('click', function () { + if (pageState.currentPage > 1) { + pageState.currentPage--; + renderBothPages().catch(console.error); + } + }); + } + + if (nextBtn) { + nextBtn.addEventListener('click', function () { + const totalPairs = + pageState.pagePairs.length || + Math.max( + pageState.pdfDoc1?.numPages || 0, + pageState.pdfDoc2?.numPages || 0 + ); + if (pageState.currentPage < totalPairs) { + pageState.currentPage++; + renderBothPages().catch(console.error); + } + }); + } + + const btnOverlay = getElement('view-mode-overlay'); + const btnSide = getElement('view-mode-side'); + + if (btnOverlay) { + btnOverlay.addEventListener('click', function () { + setViewMode('overlay'); + }); + } + + if (btnSide) { + btnSide.addEventListener('click', function () { + setViewMode('side-by-side'); + }); + } + + const flickerBtn = getElement('flicker-btn'); + const canvas2 = getElement( + 'canvas-compare-2' + ) as HTMLCanvasElement; + const opacitySlider = getElement( + 'opacity-slider' + ) as HTMLInputElement; + + // Track flicker state + let flickerVisible = true; + + if (flickerBtn) { + flickerBtn.addEventListener('click', function () { + flickerVisible = !flickerVisible; + const p2 = getElement('panel-2'); + if (p2) { + p2.style.transition = 'opacity 150ms ease-in-out'; + p2.style.opacity = flickerVisible ? opacitySlider?.value || '0.5' : '0'; + } + }); + } + + if (opacitySlider) { + opacitySlider.addEventListener('input', function () { + flickerVisible = true; + const p2 = getElement('panel-2'); + if (p2) { + p2.style.transition = ''; + p2.style.opacity = opacitySlider.value; + } + }); + } + + const panel1 = getElement('panel-1'); + const panel2 = getElement('panel-2'); + const syncToggle = getElement( + 'sync-scroll-toggle' + ) as HTMLInputElement; + const prevChangeBtn = getElement('prev-change-btn'); + const nextChangeBtn = getElement('next-change-btn'); + const exportDropdownBtn = getElement( + 'export-dropdown-btn' + ); + const exportDropdownMenu = getElement('export-dropdown-menu'); + const ocrToggle = getElement('ocr-toggle'); + const searchInput = getElement('compare-search-input'); + + const filterButtons: Array<{ id: string; filter: CompareFilterType }> = [ + { id: 'filter-modified', filter: 'modified' }, + { id: 'filter-added', filter: 'added' }, + { id: 'filter-removed', filter: 'removed' }, + { id: 'filter-moved', filter: 'moved' }, + { id: 'filter-style-changed', filter: 'style-changed' }, + ]; + + if (syncToggle) { + syncToggle.addEventListener('change', function () { + pageState.isSyncScroll = syncToggle.checked; + }); + } + + let scrollingPanel: HTMLElement | null = null; + + if (panel1 && panel2) { + panel1.addEventListener('scroll', function () { + if (pageState.isSyncScroll && scrollingPanel !== panel2) { + scrollingPanel = panel1; + panel2.scrollTop = panel1.scrollTop; + panel2.scrollLeft = panel1.scrollLeft; + setTimeout(function () { + scrollingPanel = null; + }, 100); + } + }); + + panel2.addEventListener('scroll', function () { + if (pageState.viewMode === 'overlay') return; + if (pageState.isSyncScroll && scrollingPanel !== panel1) { + scrollingPanel = panel2; + panel1.scrollTop = panel2.scrollTop; + panel1.scrollLeft = panel2.scrollLeft; + setTimeout(function () { + scrollingPanel = null; + }, 100); + } + }); + } + + if (prevChangeBtn) { + prevChangeBtn.addEventListener('click', function () { + const changes = getVisibleChanges(pageState.currentComparison); + if (changes.length === 0) return; + pageState.activeChangeIndex = + (pageState.activeChangeIndex - 1 + changes.length) % changes.length; + renderComparisonUI(); + scrollToChange(changes[pageState.activeChangeIndex]); + }); + } + + if (nextChangeBtn) { + nextChangeBtn.addEventListener('click', function () { + const changes = getVisibleChanges(pageState.currentComparison); + if (changes.length === 0) return; + pageState.activeChangeIndex = + (pageState.activeChangeIndex + 1) % changes.length; + renderComparisonUI(); + scrollToChange(changes[pageState.activeChangeIndex]); + }); + } + + const ZOOM_STEP = 0.25; + const ZOOM_MIN = 0.25; + const ZOOM_MAX = 5.0; + const zoomInBtn = getElement('zoom-in-btn'); + const zoomOutBtn = getElement('zoom-out-btn'); + const zoomResetBtn = getElement('zoom-reset-btn'); + const zoomDisplay = getElement('zoom-level-display'); + + function updateZoomDisplay() { + if (zoomDisplay) { + zoomDisplay.textContent = `${Math.round(pageState.zoomLevel * 100)}%`; + } + if (zoomOutBtn) zoomOutBtn.disabled = pageState.zoomLevel <= ZOOM_MIN; + if (zoomInBtn) zoomInBtn.disabled = pageState.zoomLevel >= ZOOM_MAX; + } + + function applyZoom() { + updateZoomDisplay(); + caches.pageModelCache.clear(); + caches.comparisonCache.clear(); + caches.comparisonResultsCache.clear(); + if (pageState.pdfDoc1 && pageState.pdfDoc2) { + renderBothPages().catch(console.error); + } + } + + if (zoomInBtn) { + zoomInBtn.addEventListener('click', function () { + pageState.zoomLevel = Math.min( + Math.round((pageState.zoomLevel + ZOOM_STEP) * 100) / 100, + ZOOM_MAX + ); + applyZoom(); + }); + } + + if (zoomOutBtn) { + zoomOutBtn.addEventListener('click', function () { + pageState.zoomLevel = Math.max( + Math.round((pageState.zoomLevel - ZOOM_STEP) * 100) / 100, + ZOOM_MIN + ); + applyZoom(); + }); + } + + if (zoomResetBtn) { + zoomResetBtn.addEventListener('click', function () { + pageState.zoomLevel = 1.0; + applyZoom(); + }); + } + + filterButtons.forEach(({ id, filter }) => { + const button = getElement(id); + if (!button) return; + button.addEventListener('click', function () { + if (pageState.activeFilter === filter) { + pageState.activeFilter = 'all'; + } else { + pageState.activeFilter = filter; + } + pageState.activeChangeIndex = 0; + renderComparisonUI(); + }); + }); + + const categoryKeys: Array = [ + 'text', + 'image', + 'header-footer', + 'annotation', + 'formatting', + 'background', + ]; + + for (const key of categoryKeys) { + const pill = getElement(`category-${key}`); + if (pill) { + pill.addEventListener('click', function () { + pageState.categoryFilter[key] = !pageState.categoryFilter[key]; + pageState.activeChangeIndex = 0; + renderComparisonUI(); + }); + } + } + + if (ocrToggle) { + ocrToggle.checked = pageState.useOcr; + ocrToggle.addEventListener('change', async function () { + try { + pageState.useOcr = ocrToggle.checked; + caches.pageModelCache.clear(); + caches.comparisonCache.clear(); + caches.comparisonResultsCache.clear(); + if (pageState.pdfDoc1 && pageState.pdfDoc2) { + await renderBothPages(); + } + } catch (e) { + console.error('OCR toggle failed:', e); + hideLoader(); + } + }); + } + + if (searchInput) { + searchInput.addEventListener('input', function () { + pageState.changeSearchQuery = searchInput.value; + pageState.activeChangeIndex = 0; + renderComparisonUI(); + }); + } + + let resizeFrame = 0; + window.addEventListener('resize', function () { + if (!pageState.pdfDoc1 || !pageState.pdfDoc2) { + return; } - handleFileInput('file-input-1', 'pdfDoc1', 'file-display-1'); - handleFileInput('file-input-2', 'pdfDoc2', 'file-display-2'); + window.cancelAnimationFrame(resizeFrame); + resizeFrame = window.requestAnimationFrame(function () { + renderBothPages().catch(console.error); + }); + }); - const prevBtn = document.getElementById('prev-page-compare'); - const nextBtn = document.getElementById('next-page-compare'); + if (exportDropdownBtn && exportDropdownMenu) { + exportDropdownBtn.addEventListener('click', function (e) { + e.stopPropagation(); + exportDropdownMenu.classList.toggle('hidden'); + }); - if (prevBtn) { - prevBtn.addEventListener('click', function () { - if (pageState.currentPage > 1) { - pageState.currentPage--; - renderBothPages(); + document.addEventListener('click', function () { + exportDropdownMenu.classList.add('hidden'); + }); + + exportDropdownMenu.addEventListener('click', function (e) { + e.stopPropagation(); + }); + + document.querySelectorAll('.export-menu-item').forEach(function (btn) { + btn.addEventListener('click', async function () { + const mode = (btn as HTMLElement).dataset + .exportMode as ComparePdfExportMode; + if (!mode || pageState.pagePairs.length === 0) return; + exportDropdownMenu.classList.add('hidden'); + try { + showLoader('Preparing PDF export...'); + await exportComparePdf( + mode, + pageState.pdfDoc1, + pageState.pdfDoc2, + pageState.pagePairs, + function (message, percent) { + showLoader(message, percent); } - }); - } + ); + } catch (e) { + console.error('PDF export failed:', e); + showAlert('Export Error', 'Could not export comparison PDF.'); + } finally { + hideLoader(); + } + }); + }); + } - if (nextBtn) { - nextBtn.addEventListener('click', function () { - const maxPages = Math.max( - pageState.pdfDoc1?.numPages || 0, - pageState.pdfDoc2?.numPages || 0 - ); - if (pageState.currentPage < maxPages) { - pageState.currentPage++; - renderBothPages(); - } - }); - } - - const btnOverlay = document.getElementById('view-mode-overlay'); - const btnSide = document.getElementById('view-mode-side'); - - if (btnOverlay) { - btnOverlay.addEventListener('click', function () { - setViewMode('overlay'); - }); - } - - if (btnSide) { - btnSide.addEventListener('click', function () { - setViewMode('side-by-side'); - }); - } - - const flickerBtn = document.getElementById('flicker-btn'); - const canvas2 = document.getElementById('canvas-compare-2') as HTMLCanvasElement; - const opacitySlider = document.getElementById('opacity-slider') as HTMLInputElement; - - // Track flicker state - let flickerVisible = true; - - if (flickerBtn && canvas2) { - flickerBtn.addEventListener('click', function () { - flickerVisible = !flickerVisible; - canvas2.style.transition = 'opacity 150ms ease-in-out'; - canvas2.style.opacity = flickerVisible ? (opacitySlider?.value || '0.5') : '0'; - }); - } - - if (opacitySlider && canvas2) { - opacitySlider.addEventListener('input', function () { - flickerVisible = true; // Reset flicker state when slider changes - canvas2.style.transition = ''; - canvas2.style.opacity = opacitySlider.value; - }); - } - - const panel1 = document.getElementById('panel-1'); - const panel2 = document.getElementById('panel-2'); - const syncToggle = document.getElementById('sync-scroll-toggle') as HTMLInputElement; - - if (syncToggle) { - syncToggle.addEventListener('change', function () { - pageState.isSyncScroll = syncToggle.checked; - }); - } - - let scrollingPanel: HTMLElement | null = null; - - if (panel1 && panel2) { - panel1.addEventListener('scroll', function () { - if (pageState.isSyncScroll && scrollingPanel !== panel2) { - scrollingPanel = panel1; - panel2.scrollTop = panel1.scrollTop; - setTimeout(function () { scrollingPanel = null; }, 100); - } - }); - - panel2.addEventListener('scroll', function () { - if (pageState.isSyncScroll && scrollingPanel !== panel1) { - scrollingPanel = panel2; - panel1.scrollTop = panel2.scrollTop; - setTimeout(function () { scrollingPanel = null; }, 100); - } - }); - } - - createIcons({ icons }); + createIcons({ icons }); + updateFilterButtons(); + setViewMode(pageState.viewMode); }); diff --git a/src/js/logic/compare-render.ts b/src/js/logic/compare-render.ts new file mode 100644 index 0000000..ffdc2c9 --- /dev/null +++ b/src/js/logic/compare-render.ts @@ -0,0 +1,480 @@ +import * as pdfjsLib from 'pdfjs-dist'; +import type { + ComparePageModel, + ComparePagePair, + ComparePageResult, + CompareRectangle, + CompareWordToken, + CompareTextItem, + RenderedPage, + ComparisonPageLoad, + DiffFocusRegion, + CompareCaches, + CompareRenderContext, +} from '../compare/types.ts'; +import { extractPageModel } from '../compare/engine/extract-page-model.ts'; +import { comparePageModelsAsync } from '../compare/engine/compare-page-models.ts'; +import { renderVisualDiff } from '../compare/engine/visual-diff.ts'; +import { recognizePageCanvas } from '../compare/engine/ocr-page.ts'; +import { isLowQualityExtractedText } from '../compare/engine/text-normalization.ts'; +import { COMPARE_RENDER, COMPARE_GEOMETRY } from '../compare/config.ts'; + +export function getElement(id: string) { + return document.getElementById(id) as T | null; +} + +export function clearCanvas(canvas: HTMLCanvasElement) { + const context = canvas.getContext('2d'); + canvas.width = 1; + canvas.height = 1; + context?.clearRect(0, 0, 1, 1); +} + +export function renderMissingPage( + canvas: HTMLCanvasElement, + placeholderId: string, + message: string +) { + clearCanvas(canvas); + const placeholder = getElement(placeholderId); + if (placeholder) { + placeholder.textContent = message; + placeholder.classList.remove('hidden'); + } +} + +export function hidePlaceholder(placeholderId: string) { + const placeholder = getElement(placeholderId); + placeholder?.classList.add('hidden'); +} + +export function getRenderScale( + page: pdfjsLib.PDFPageProxy, + container: HTMLElement, + viewMode: 'overlay' | 'side-by-side', + zoomLevel = 1.0 +) { + const baseViewport = page.getViewport({ scale: 1.0 }); + const availableWidth = Math.max( + container.clientWidth - (viewMode === 'overlay' ? 96 : 56), + 320 + ); + const fitScale = availableWidth / Math.max(baseViewport.width, 1); + const maxScale = + viewMode === 'overlay' + ? COMPARE_RENDER.MAX_SCALE_OVERLAY + : COMPARE_RENDER.MAX_SCALE_SIDE; + + const baseScale = Math.min(Math.max(fitScale, 1.0), maxScale); + return baseScale * zoomLevel; +} + +export function getPageModelCacheKey( + cacheKeyPrefix: 'left' | 'right', + pageNum: number, + scale: number +) { + return `${cacheKeyPrefix}-${pageNum}-${scale.toFixed(3)}`; +} + +function shouldUseOcrForModel(model: ComparePageModel) { + return !model.hasText || isLowQualityExtractedText(model.plainText); +} + +function rescaleRect( + rect: CompareRectangle, + scaleX: number, + scaleY: number +): CompareRectangle { + return { + x: rect.x * scaleX, + y: rect.y * scaleY, + width: rect.width * scaleX, + height: rect.height * scaleY, + }; +} + +function rescaleWordToken( + token: CompareWordToken, + scaleX: number, + scaleY: number +): CompareWordToken { + return { + ...token, + rect: rescaleRect(token.rect, scaleX, scaleY), + }; +} + +function rescaleTextItem( + item: CompareTextItem, + scaleX: number, + scaleY: number +): CompareTextItem { + return { + ...item, + rect: rescaleRect(item.rect, scaleX, scaleY), + charMap: item.charMap?.map((c) => ({ + x: c.x * scaleX, + width: c.width * scaleX, + })), + wordTokens: item.wordTokens?.map((t) => + rescaleWordToken(t, scaleX, scaleY) + ), + fragments: item.fragments?.map((f) => rescaleTextItem(f, scaleX, scaleY)), + }; +} + +function rescalePageModel( + model: ComparePageModel, + cachedWidth: number, + cachedHeight: number, + targetWidth: number, + targetHeight: number +): ComparePageModel { + const scaleX = targetWidth / Math.max(cachedWidth, 1); + const scaleY = targetHeight / Math.max(cachedHeight, 1); + return { + ...model, + width: targetWidth, + height: targetHeight, + textItems: model.textItems.map((item) => + rescaleTextItem(item, scaleX, scaleY) + ), + }; +} + +function getOcrCacheKey(side: string, pageNum: number) { + return `${side}-${pageNum}`; +} + +export function buildDiffFocusRegion( + comparison: ComparePageResult, + leftCanvas: HTMLCanvasElement, + rightCanvas: HTMLCanvasElement +): DiffFocusRegion | undefined { + const leftOffsetX = Math.floor( + (Math.max(leftCanvas.width, rightCanvas.width) - leftCanvas.width) / 2 + ); + const leftOffsetY = Math.floor( + (Math.max(leftCanvas.height, rightCanvas.height) - leftCanvas.height) / 2 + ); + const rightOffsetX = Math.floor( + (Math.max(leftCanvas.width, rightCanvas.width) - rightCanvas.width) / 2 + ); + const rightOffsetY = Math.floor( + (Math.max(leftCanvas.height, rightCanvas.height) - rightCanvas.height) / 2 + ); + const bounds = { + minX: Infinity, + minY: Infinity, + maxX: -Infinity, + maxY: -Infinity, + }; + + for (const change of comparison.changes) { + for (const rect of change.beforeRects) { + bounds.minX = Math.min(bounds.minX, rect.x + leftOffsetX); + bounds.minY = Math.min(bounds.minY, rect.y + leftOffsetY); + bounds.maxX = Math.max(bounds.maxX, rect.x + leftOffsetX + rect.width); + bounds.maxY = Math.max(bounds.maxY, rect.y + leftOffsetY + rect.height); + } + + for (const rect of change.afterRects) { + bounds.minX = Math.min(bounds.minX, rect.x + rightOffsetX); + bounds.minY = Math.min(bounds.minY, rect.y + rightOffsetY); + bounds.maxX = Math.max(bounds.maxX, rect.x + rightOffsetX + rect.width); + bounds.maxY = Math.max(bounds.maxY, rect.y + rightOffsetY + rect.height); + } + } + + if (!Number.isFinite(bounds.minX)) { + return undefined; + } + + const fullWidth = Math.max(leftCanvas.width, rightCanvas.width, 1); + const fullHeight = Math.max(leftCanvas.height, rightCanvas.height, 1); + const padding = COMPARE_GEOMETRY.FOCUS_REGION_PADDING; + + const x = Math.max(Math.floor(bounds.minX - padding), 0); + const y = Math.max(Math.floor(bounds.minY - padding), 0); + const maxX = Math.min(Math.ceil(bounds.maxX + padding), fullWidth); + const maxY = Math.min(Math.ceil(bounds.maxY + padding), fullHeight); + + return { + x, + y, + width: Math.max( + maxX - x, + Math.min(COMPARE_GEOMETRY.FOCUS_REGION_MIN_WIDTH, fullWidth) + ), + height: Math.max( + maxY - y, + Math.min(COMPARE_GEOMETRY.FOCUS_REGION_MIN_HEIGHT, fullHeight) + ), + }; +} + +export async function renderPage( + pdfDoc: pdfjsLib.PDFDocumentProxy, + pageNum: number, + canvas: HTMLCanvasElement, + container: HTMLElement, + placeholderId: string, + cacheKeyPrefix: 'left' | 'right', + caches: CompareCaches, + ctx: CompareRenderContext +): Promise { + if (pageNum > pdfDoc.numPages) { + renderMissingPage( + canvas, + placeholderId, + `Page ${pageNum} does not exist in this PDF.` + ); + return { model: null, exists: false }; + } + + const page = await pdfDoc.getPage(pageNum); + + const targetScale = getRenderScale( + page, + container, + ctx.viewMode, + ctx.zoomLevel + ); + const scaledViewport = page.getViewport({ scale: targetScale }); + const dpr = window.devicePixelRatio || 1; + const hiResViewport = page.getViewport({ scale: targetScale * dpr }); + + hidePlaceholder(placeholderId); + + canvas.width = hiResViewport.width; + canvas.height = hiResViewport.height; + canvas.style.width = `${scaledViewport.width}px`; + canvas.style.height = `${scaledViewport.height}px`; + + const cacheKey = getPageModelCacheKey(cacheKeyPrefix, pageNum, targetScale); + const cachedModel = caches.pageModelCache.get(cacheKey); + const modelPromise = cachedModel + ? Promise.resolve(cachedModel) + : extractPageModel(page, scaledViewport); + const renderTask = page.render({ + canvasContext: canvas.getContext('2d')!, + viewport: hiResViewport, + canvas, + }).promise; + + const [model] = await Promise.all([modelPromise, renderTask]); + + let finalModel = model; + + if (!cachedModel && ctx.useOcr && shouldUseOcrForModel(model)) { + const ocrKey = getOcrCacheKey(cacheKeyPrefix, pageNum); + const cachedOcr = caches.ocrModelCache.get(ocrKey); + if (cachedOcr) { + finalModel = rescalePageModel( + cachedOcr.model, + cachedOcr.width, + cachedOcr.height, + scaledViewport.width, + scaledViewport.height + ); + finalModel.pageNumber = pageNum; + } else { + ctx.showLoader(`Running OCR on page ${pageNum}...`); + const ocrModel = await recognizePageCanvas( + canvas, + ctx.ocrLanguage, + function (status, progress) { + ctx.showLoader(`OCR: ${status}`, progress * 100); + } + ); + finalModel = { + ...ocrModel, + pageNumber: pageNum, + }; + caches.ocrModelCache.set(ocrKey, { + model: finalModel, + width: scaledViewport.width, + height: scaledViewport.height, + }); + } + } + + caches.pageModelCache.set(cacheKey, finalModel); + + return { model: finalModel, exists: true }; +} + +export async function loadComparisonPage( + pdfDoc: pdfjsLib.PDFDocumentProxy | null, + pageNum: number | null, + side: 'left' | 'right', + renderTarget: + | { + canvas: HTMLCanvasElement; + container: HTMLElement; + placeholderId: string; + } + | undefined, + caches: CompareCaches, + ctx: CompareRenderContext +): Promise { + if (!pdfDoc || !pageNum) { + if (renderTarget) { + renderMissingPage( + renderTarget.canvas, + renderTarget.placeholderId, + 'No paired page for this side.' + ); + } + return { model: null, exists: false }; + } + + if (renderTarget) { + return renderPage( + pdfDoc, + pageNum, + renderTarget.canvas, + renderTarget.container, + renderTarget.placeholderId, + side, + caches, + ctx + ); + } + + const renderScale = COMPARE_RENDER.OFFLINE_SCALE; + const cacheKey = getPageModelCacheKey(side, pageNum, renderScale); + const cachedModel = caches.pageModelCache.get(cacheKey); + if (cachedModel) { + return { model: cachedModel, exists: true }; + } + + const page = await pdfDoc.getPage(pageNum); + const viewport = page.getViewport({ scale: renderScale }); + const canvas = document.createElement('canvas'); + canvas.width = viewport.width; + canvas.height = viewport.height; + const context = canvas.getContext('2d'); + + if (!context) { + throw new Error('Could not create offscreen comparison canvas.'); + } + + const extractedModel = await extractPageModel(page, viewport); + await page.render({ + canvasContext: context, + viewport, + canvas, + }).promise; + + let finalModel = extractedModel; + if (ctx.useOcr && shouldUseOcrForModel(extractedModel)) { + const ocrKey = getOcrCacheKey(side, pageNum); + const cachedOcr = caches.ocrModelCache.get(ocrKey); + if (cachedOcr) { + finalModel = rescalePageModel( + cachedOcr.model, + cachedOcr.width, + cachedOcr.height, + viewport.width, + viewport.height + ); + finalModel.pageNumber = pageNum; + } else { + const ocrModel = await recognizePageCanvas(canvas, ctx.ocrLanguage); + finalModel = { + ...ocrModel, + pageNumber: pageNum, + }; + caches.ocrModelCache.set(ocrKey, { + model: finalModel, + width: viewport.width, + height: viewport.height, + }); + } + } + + canvas.width = 0; + canvas.height = 0; + + caches.pageModelCache.set(cacheKey, finalModel); + return { model: finalModel, exists: true }; +} + +export async function computeComparisonForPair( + pdfDoc1: pdfjsLib.PDFDocumentProxy | null, + pdfDoc2: pdfjsLib.PDFDocumentProxy | null, + pair: ComparePagePair, + caches: CompareCaches, + ctx: CompareRenderContext, + options?: { + renderTargets?: { + left: { + canvas: HTMLCanvasElement; + container: HTMLElement; + placeholderId: string; + }; + right: { + canvas: HTMLCanvasElement; + container: HTMLElement; + placeholderId: string; + }; + diffCanvas?: HTMLCanvasElement; + }; + } +) { + const renderTargets = options?.renderTargets; + const leftPage = await loadComparisonPage( + pdfDoc1, + pair.leftPageNumber, + 'left', + renderTargets?.left, + caches, + ctx + ); + const rightPage = await loadComparisonPage( + pdfDoc2, + pair.rightPageNumber, + 'right', + renderTargets?.right, + caches, + ctx + ); + + const comparison = await comparePageModelsAsync( + leftPage.model, + rightPage.model + ); + comparison.confidence = pair.confidence; + + if ( + renderTargets?.diffCanvas && + comparison.status !== 'left-only' && + comparison.status !== 'right-only' + ) { + const focusRegion = buildDiffFocusRegion( + comparison, + renderTargets.left.canvas, + renderTargets.right.canvas + ); + comparison.visualDiff = renderVisualDiff( + renderTargets.left.canvas, + renderTargets.right.canvas, + renderTargets.diffCanvas, + focusRegion + ); + } else if (renderTargets?.diffCanvas) { + clearCanvas(renderTargets.diffCanvas); + } + + return comparison; +} + +export function getComparisonCacheKey(pair: ComparePagePair, useOcr: boolean) { + const leftKey = pair.leftPageNumber ? `left-${pair.leftPageNumber}` : 'none'; + const rightKey = pair.rightPageNumber + ? `right-${pair.rightPageNumber}` + : 'none'; + return `${leftKey}:${rightKey}:${useOcr ? 'ocr' : 'no-ocr'}`; +} diff --git a/src/js/logic/compress-pdf-page.ts b/src/js/logic/compress-pdf-page.ts index 6777f36..f4d8cd2 100644 --- a/src/js/logic/compress-pdf-page.ts +++ b/src/js/logic/compress-pdf-page.ts @@ -6,10 +6,10 @@ import { getPDFDocument, } from '../utils/helpers.js'; import { state } from '../state.js'; -import { createIcons, icons } from 'lucide'; import { PDFDocument } from 'pdf-lib'; -import { PyMuPDF } from '@bentopdf/pymupdf-wasm'; -import { getWasmBaseUrl } from '../config/wasm-cdn-config.js'; +import { createIcons, icons } from 'lucide'; +import { showWasmRequiredDialog } from '../utils/wasm-provider.js'; +import { loadPyMuPDF, isPyMuPDFAvailable } from '../utils/pymupdf-loader.js'; import * as pdfjsLib from 'pdfjs-dist'; pdfjsLib.GlobalWorkerOptions.workerSrc = new URL( @@ -60,8 +60,8 @@ async function performCondenseCompression( removeThumbnails?: boolean; } ) { - const pymupdf = new PyMuPDF(getWasmBaseUrl('pymupdf')); - await pymupdf.load(); + // Load PyMuPDF dynamically from user-provided URL + const pymupdf = await loadPyMuPDF(); const preset = CONDENSE_PRESETS[level as keyof typeof CONDENSE_PRESETS] || @@ -390,6 +390,15 @@ document.addEventListener('DOMContentLoaded', () => { return; } + // Check WASM availability for Condense mode + const algorithm = ( + document.getElementById('compression-algorithm') as HTMLSelectElement + ).value; + if (algorithm === 'condense' && !isPyMuPDFAvailable()) { + showWasmRequiredDialog('pymupdf'); + return; + } + if (state.files.length === 1) { const originalFile = state.files[0]; diff --git a/src/js/logic/crop-pdf-page.ts b/src/js/logic/crop-pdf-page.ts index b399e04..c509b24 100644 --- a/src/js/logic/crop-pdf-page.ts +++ b/src/js/logic/crop-pdf-page.ts @@ -1,373 +1,442 @@ import { createIcons, icons } from 'lucide'; import { showLoader, hideLoader, showAlert } from '../ui.js'; -import { downloadFile, readFileAsArrayBuffer, formatBytes, getPDFDocument } from '../utils/helpers.js'; +import { + downloadFile, + readFileAsArrayBuffer, + formatBytes, + getPDFDocument, +} from '../utils/helpers.js'; import Cropper from 'cropperjs'; import * as pdfjsLib from 'pdfjs-dist'; import { PDFDocument as PDFLibDocument } from 'pdf-lib'; import { CropperState } from '@/types'; -pdfjsLib.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).toString(); +pdfjsLib.GlobalWorkerOptions.workerSrc = new URL( + 'pdfjs-dist/build/pdf.worker.min.mjs', + import.meta.url +).toString(); const cropperState: CropperState = { - pdfDoc: null, - currentPageNum: 1, - cropper: null, - originalPdfBytes: null, - pageCrops: {}, - file: null, + pdfDoc: null, + currentPageNum: 1, + cropper: null, + originalPdfBytes: null, + pageCrops: {}, + file: null, }; if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', initializePage); + document.addEventListener('DOMContentLoaded', initializePage); } else { - initializePage(); + initializePage(); } function initializePage() { - createIcons({ icons }); + createIcons({ icons }); - const fileInput = document.getElementById('file-input') as HTMLInputElement; - const dropZone = document.getElementById('drop-zone'); + const fileInput = document.getElementById('file-input') as HTMLInputElement; + const dropZone = document.getElementById('drop-zone'); - if (fileInput) fileInput.addEventListener('change', handleFileUpload); + if (fileInput) fileInput.addEventListener('change', handleFileUpload); - 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]); - }); - // Clear value on click to allow re-selecting the same file - fileInput?.addEventListener('click', () => { - if (fileInput) fileInput.value = ''; - }); - } - - document.getElementById('back-to-tools')?.addEventListener('click', () => { - window.location.href = import.meta.env.BASE_URL; + 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]); + }); + // Clear value on click to allow re-selecting the same file + fileInput?.addEventListener('click', () => { + if (fileInput) fileInput.value = ''; + }); + } - document.getElementById('prev-page')?.addEventListener('click', () => changePage(-1)); - document.getElementById('next-page')?.addEventListener('click', () => changePage(1)); - document.getElementById('crop-button')?.addEventListener('click', performCrop); + document.getElementById('back-to-tools')?.addEventListener('click', () => { + window.location.href = import.meta.env.BASE_URL; + }); + + document + .getElementById('prev-page') + ?.addEventListener('click', () => changePage(-1)); + document + .getElementById('next-page') + ?.addEventListener('click', () => changePage(1)); + document + .getElementById('crop-button') + ?.addEventListener('click', performCrop); } function handleFileUpload(e: Event) { - const input = e.target as HTMLInputElement; - if (input.files && input.files.length > 0) handleFile(input.files[0]); + const input = e.target as HTMLInputElement; + if (input.files && input.files.length > 0) handleFile(input.files[0]); } async function handleFile(file: File) { - if (file.type !== 'application/pdf' && !file.name.toLowerCase().endsWith('.pdf')) { - showAlert('Invalid File', 'Please select a PDF file.'); - return; - } + if ( + file.type !== 'application/pdf' && + !file.name.toLowerCase().endsWith('.pdf') + ) { + showAlert('Invalid File', 'Please select a PDF file.'); + return; + } - showLoader('Loading PDF...'); - cropperState.file = file; - cropperState.pageCrops = {}; + showLoader('Loading PDF...'); + cropperState.file = file; + cropperState.pageCrops = {}; - try { - const arrayBuffer = await readFileAsArrayBuffer(file); - cropperState.originalPdfBytes = arrayBuffer as ArrayBuffer; - cropperState.pdfDoc = await getPDFDocument({ data: (arrayBuffer as ArrayBuffer).slice(0) }).promise; - cropperState.currentPageNum = 1; + try { + const arrayBuffer = await readFileAsArrayBuffer(file); + cropperState.originalPdfBytes = arrayBuffer as ArrayBuffer; + cropperState.pdfDoc = await getPDFDocument({ + data: (arrayBuffer as ArrayBuffer).slice(0), + }).promise; + cropperState.currentPageNum = 1; - updateFileDisplay(); - await displayPageAsImage(cropperState.currentPageNum); - hideLoader(); - } catch (error) { - console.error('Error loading PDF:', error); - hideLoader(); - showAlert('Error', 'Failed to load PDF file.'); - } + updateFileDisplay(); + await displayPageAsImage(cropperState.currentPageNum); + hideLoader(); + } catch (error) { + console.error('Error loading PDF:', error); + hideLoader(); + showAlert('Error', 'Failed to load PDF file.'); + } } function updateFileDisplay() { - const fileDisplayArea = document.getElementById('file-display-area'); - if (!fileDisplayArea || !cropperState.file) return; + const fileDisplayArea = document.getElementById('file-display-area'); + if (!fileDisplayArea || !cropperState.file) return; - fileDisplayArea.innerHTML = ''; - const fileDiv = document.createElement('div'); - fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg'; + 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 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 = cropperState.file.name; + const nameSpan = document.createElement('div'); + nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1'; + nameSpan.textContent = cropperState.file.name; - const metaSpan = document.createElement('div'); - metaSpan.className = 'text-xs text-gray-400'; - metaSpan.textContent = `${formatBytes(cropperState.file.size)} • ${cropperState.pdfDoc?.numPages || 0} pages`; + const metaSpan = document.createElement('div'); + metaSpan.className = 'text-xs text-gray-400'; + metaSpan.textContent = `${formatBytes(cropperState.file.size)} • ${cropperState.pdfDoc?.numPages || 0} pages`; - infoContainer.append(nameSpan, metaSpan); + infoContainer.append(nameSpan, metaSpan); - const removeBtn = document.createElement('button'); - removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0'; - removeBtn.innerHTML = ''; - removeBtn.onclick = () => resetState(); + 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 }); + fileDiv.append(infoContainer, removeBtn); + fileDisplayArea.appendChild(fileDiv); + createIcons({ icons }); } function saveCurrentCrop() { - if (cropperState.cropper) { - const currentCrop = cropperState.cropper.getData(true); - const imageData = cropperState.cropper.getImageData(); - const cropPercentages = { - x: currentCrop.x / imageData.naturalWidth, - y: currentCrop.y / imageData.naturalHeight, - width: currentCrop.width / imageData.naturalWidth, - height: currentCrop.height / imageData.naturalHeight, - }; - cropperState.pageCrops[cropperState.currentPageNum] = cropPercentages; - } + if (cropperState.cropper) { + const currentCrop = cropperState.cropper.getData(true); + const imageData = cropperState.cropper.getImageData(); + const cropPercentages = { + x: currentCrop.x / imageData.naturalWidth, + y: currentCrop.y / imageData.naturalHeight, + width: currentCrop.width / imageData.naturalWidth, + height: currentCrop.height / imageData.naturalHeight, + }; + cropperState.pageCrops[cropperState.currentPageNum] = cropPercentages; + } } async function displayPageAsImage(num: number) { - showLoader(`Rendering Page ${num}...`); + showLoader(`Rendering Page ${num}...`); - try { - const page = await cropperState.pdfDoc.getPage(num); - const viewport = page.getViewport({ scale: 2.5 }); + try { + const page = await cropperState.pdfDoc.getPage(num); + const viewport = page.getViewport({ scale: 2.5 }); - const tempCanvas = document.createElement('canvas'); - const tempCtx = tempCanvas.getContext('2d'); - tempCanvas.width = viewport.width; - tempCanvas.height = viewport.height; - await page.render({ canvasContext: tempCtx, viewport: viewport }).promise; + const tempCanvas = document.createElement('canvas'); + const tempCtx = tempCanvas.getContext('2d'); + tempCanvas.width = viewport.width; + tempCanvas.height = viewport.height; + await page.render({ canvasContext: tempCtx, viewport: viewport }).promise; - if (cropperState.cropper) cropperState.cropper.destroy(); + if (cropperState.cropper) cropperState.cropper.destroy(); - const cropperEditor = document.getElementById('cropper-editor'); - if (cropperEditor) cropperEditor.classList.remove('hidden'); + const cropperEditor = document.getElementById('cropper-editor'); + if (cropperEditor) cropperEditor.classList.remove('hidden'); - const container = document.getElementById('cropper-container'); - if (!container) return; + const container = document.getElementById('cropper-container'); + if (!container) return; - container.innerHTML = ''; - const image = document.createElement('img'); - image.src = tempCanvas.toDataURL('image/png'); - container.appendChild(image); + container.innerHTML = ''; + const image = document.createElement('img'); + image.src = tempCanvas.toDataURL('image/png'); + container.appendChild(image); - image.onload = () => { - cropperState.cropper = new Cropper(image, { - viewMode: 1, - background: false, - autoCropArea: 0.8, - responsive: true, - rotatable: false, - zoomable: false, - }); + image.onload = () => { + cropperState.cropper = new Cropper(image, { + viewMode: 1, + background: false, + autoCropArea: 0.8, + responsive: true, + rotatable: false, + zoomable: false, + }); - const savedCrop = cropperState.pageCrops[num]; - if (savedCrop) { - const imageData = cropperState.cropper.getImageData(); - cropperState.cropper.setData({ - x: savedCrop.x * imageData.naturalWidth, - y: savedCrop.y * imageData.naturalHeight, - width: savedCrop.width * imageData.naturalWidth, - height: savedCrop.height * imageData.naturalHeight, - }); - } + const savedCrop = cropperState.pageCrops[num]; + if (savedCrop) { + const imageData = cropperState.cropper.getImageData(); + cropperState.cropper.setData({ + x: savedCrop.x * imageData.naturalWidth, + y: savedCrop.y * imageData.naturalHeight, + width: savedCrop.width * imageData.naturalWidth, + height: savedCrop.height * imageData.naturalHeight, + }); + } - updatePageInfo(); - enableControls(); - hideLoader(); - }; - } catch (error) { - console.error('Error rendering page:', error); - showAlert('Error', 'Failed to render page.'); - hideLoader(); - } + updatePageInfo(); + enableControls(); + hideLoader(); + }; + } catch (error) { + console.error('Error rendering page:', error); + showAlert('Error', 'Failed to render page.'); + hideLoader(); + } } async function changePage(offset: number) { - saveCurrentCrop(); - const newPageNum = cropperState.currentPageNum + offset; - if (newPageNum > 0 && newPageNum <= cropperState.pdfDoc.numPages) { - cropperState.currentPageNum = newPageNum; - await displayPageAsImage(cropperState.currentPageNum); - } + saveCurrentCrop(); + const newPageNum = cropperState.currentPageNum + offset; + if (newPageNum > 0 && newPageNum <= cropperState.pdfDoc.numPages) { + cropperState.currentPageNum = newPageNum; + await displayPageAsImage(cropperState.currentPageNum); + } } function updatePageInfo() { - const pageInfo = document.getElementById('page-info'); - if (pageInfo) pageInfo.textContent = `Page ${cropperState.currentPageNum} of ${cropperState.pdfDoc.numPages}`; + const pageInfo = document.getElementById('page-info'); + if (pageInfo) + pageInfo.textContent = `Page ${cropperState.currentPageNum} of ${cropperState.pdfDoc.numPages}`; } function enableControls() { - const prevBtn = document.getElementById('prev-page') as HTMLButtonElement; - const nextBtn = document.getElementById('next-page') as HTMLButtonElement; - const cropBtn = document.getElementById('crop-button') as HTMLButtonElement; + const prevBtn = document.getElementById('prev-page') as HTMLButtonElement; + const nextBtn = document.getElementById('next-page') as HTMLButtonElement; + const cropBtn = document.getElementById('crop-button') as HTMLButtonElement; - if (prevBtn) prevBtn.disabled = cropperState.currentPageNum <= 1; - if (nextBtn) nextBtn.disabled = cropperState.currentPageNum >= cropperState.pdfDoc.numPages; - if (cropBtn) cropBtn.disabled = false; + if (prevBtn) prevBtn.disabled = cropperState.currentPageNum <= 1; + if (nextBtn) + nextBtn.disabled = + cropperState.currentPageNum >= cropperState.pdfDoc.numPages; + if (cropBtn) cropBtn.disabled = false; } async function performCrop() { - saveCurrentCrop(); + saveCurrentCrop(); - const isDestructive = (document.getElementById('destructive-crop-toggle') as HTMLInputElement)?.checked; - const isApplyToAll = (document.getElementById('apply-to-all-toggle') as HTMLInputElement)?.checked; + const isDestructive = ( + document.getElementById('destructive-crop-toggle') as HTMLInputElement + )?.checked; + const isApplyToAll = ( + document.getElementById('apply-to-all-toggle') as HTMLInputElement + )?.checked; - let finalCropData: Record = {}; + let finalCropData: Record = {}; - if (isApplyToAll) { - const currentCrop = cropperState.pageCrops[cropperState.currentPageNum]; - if (!currentCrop) { - showAlert('No Crop Area', 'Please select an area to crop first.'); - return; - } - for (let i = 1; i <= cropperState.pdfDoc.numPages; i++) { - finalCropData[i] = currentCrop; - } + if (isApplyToAll) { + const currentCrop = cropperState.pageCrops[cropperState.currentPageNum]; + if (!currentCrop) { + showAlert('No Crop Area', 'Please select an area to crop first.'); + return; + } + for (let i = 1; i <= cropperState.pdfDoc.numPages; i++) { + finalCropData[i] = currentCrop; + } + } else { + finalCropData = { ...cropperState.pageCrops }; + } + + if (Object.keys(finalCropData).length === 0) { + showAlert( + 'No Crop Area', + 'Please select an area on at least one page to crop.' + ); + return; + } + + showLoader('Applying crop...'); + + try { + let finalPdfBytes; + if (isDestructive) { + finalPdfBytes = await performFlatteningCrop(finalCropData); } else { - finalCropData = { ...cropperState.pageCrops }; + finalPdfBytes = await performMetadataCrop(finalCropData); } - if (Object.keys(finalCropData).length === 0) { - showAlert('No Crop Area', 'Please select an area on at least one page to crop.'); - return; - } - - showLoader('Applying crop...'); - - try { - let finalPdfBytes; - if (isDestructive) { - finalPdfBytes = await performFlatteningCrop(finalCropData); - } else { - finalPdfBytes = await performMetadataCrop(finalCropData); - } - - const fileName = isDestructive ? 'flattened_crop.pdf' : 'standard_crop.pdf'; - downloadFile(new Blob([finalPdfBytes], { type: 'application/pdf' }), fileName); - showAlert('Success', 'Crop complete! Your download has started.', 'success', () => resetState()); - } catch (e) { - console.error(e); - showAlert('Error', 'An error occurred during cropping.'); - } finally { - hideLoader(); - } + const fileName = isDestructive ? 'flattened_crop.pdf' : 'standard_crop.pdf'; + downloadFile( + new Blob([finalPdfBytes], { type: 'application/pdf' }), + fileName + ); + showAlert( + 'Success', + 'Crop complete! Your download has started.', + 'success', + () => resetState() + ); + } catch (e) { + console.error(e); + showAlert('Error', 'An error occurred during cropping.'); + } finally { + hideLoader(); + } } -async function performMetadataCrop(cropData: Record): Promise { - const pdfToModify = await PDFLibDocument.load(cropperState.originalPdfBytes!, { ignoreEncryption: true, throwOnInvalidObject: false }); +async function performMetadataCrop( + cropData: Record +): Promise { + const pdfToModify = await PDFLibDocument.load( + cropperState.originalPdfBytes!, + { ignoreEncryption: true, throwOnInvalidObject: false } + ); - for (const pageNum in cropData) { - const pdfJsPage = await cropperState.pdfDoc.getPage(Number(pageNum)); - const viewport = pdfJsPage.getViewport({ scale: 1 }); - const crop = cropData[pageNum]; + for (const pageNum in cropData) { + const pdfJsPage = await cropperState.pdfDoc.getPage(Number(pageNum)); + const viewport = pdfJsPage.getViewport({ scale: 1 }); + const crop = cropData[pageNum]; - const cropX = viewport.width * crop.x; - const cropY = viewport.height * crop.y; - const cropW = viewport.width * crop.width; - const cropH = viewport.height * crop.height; + const cropX = viewport.width * crop.x; + const cropY = viewport.height * crop.y; + const cropW = viewport.width * crop.width; + const cropH = viewport.height * crop.height; - const visualCorners = [ - { x: cropX, y: cropY }, - { x: cropX + cropW, y: cropY }, - { x: cropX + cropW, y: cropY + cropH }, - { x: cropX, y: cropY + cropH }, - ]; + const visualCorners = [ + { x: cropX, y: cropY }, + { x: cropX + cropW, y: cropY }, + { x: cropX + cropW, y: cropY + cropH }, + { x: cropX, y: cropY + cropH }, + ]; - const pdfCorners = visualCorners.map(p => viewport.convertToPdfPoint(p.x, p.y)); - const pdfXs = pdfCorners.map(p => p[0]); - const pdfYs = pdfCorners.map(p => p[1]); + const pdfCorners = visualCorners.map((p) => + viewport.convertToPdfPoint(p.x, p.y) + ); + const pdfXs = pdfCorners.map((p) => p[0]); + const pdfYs = pdfCorners.map((p) => p[1]); - const minX = Math.min(...pdfXs); - const maxX = Math.max(...pdfXs); - const minY = Math.min(...pdfYs); - const maxY = Math.max(...pdfYs); + const minX = Math.min(...pdfXs); + const maxX = Math.max(...pdfXs); + const minY = Math.min(...pdfYs); + const maxY = Math.max(...pdfYs); - const page = pdfToModify.getPages()[Number(pageNum) - 1]; - page.setCropBox(minX, minY, maxX - minX, maxY - minY); - } + const page = pdfToModify.getPages()[Number(pageNum) - 1]; + page.setCropBox(minX, minY, maxX - minX, maxY - minY); + } - return pdfToModify.save(); + return pdfToModify.save(); } -async function performFlatteningCrop(cropData: Record): Promise { - const newPdfDoc = await PDFLibDocument.create(); - const sourcePdfDocForCopying = await PDFLibDocument.load(cropperState.originalPdfBytes!, { ignoreEncryption: true, throwOnInvalidObject: false }); - const totalPages = cropperState.pdfDoc.numPages; +async function performFlatteningCrop( + cropData: Record +): Promise { + const newPdfDoc = await PDFLibDocument.create(); + const sourcePdfDocForCopying = await PDFLibDocument.load( + cropperState.originalPdfBytes!, + { ignoreEncryption: true, throwOnInvalidObject: false } + ); + const totalPages = cropperState.pdfDoc.numPages; - for (let i = 0; i < totalPages; i++) { - const pageNum = i + 1; - showLoader(`Processing page ${pageNum} of ${totalPages}...`); + for (let i = 0; i < totalPages; i++) { + const pageNum = i + 1; + showLoader(`Processing page ${pageNum} of ${totalPages}...`); - if (cropData[pageNum]) { - const page = await cropperState.pdfDoc.getPage(pageNum); - const viewport = page.getViewport({ scale: 2.5 }); + if (cropData[pageNum]) { + const page = await cropperState.pdfDoc.getPage(pageNum); + const viewport = page.getViewport({ scale: 2.5 }); - const tempCanvas = document.createElement('canvas'); - const tempCtx = tempCanvas.getContext('2d'); - tempCanvas.width = viewport.width; - tempCanvas.height = viewport.height; - await page.render({ canvasContext: tempCtx, viewport: viewport }).promise; + const tempCanvas = document.createElement('canvas'); + const tempCtx = tempCanvas.getContext('2d'); + tempCanvas.width = viewport.width; + tempCanvas.height = viewport.height; + await page.render({ canvasContext: tempCtx, viewport: viewport }).promise; - const finalCanvas = document.createElement('canvas'); - const finalCtx = finalCanvas.getContext('2d'); - const crop = cropData[pageNum]; - const finalWidth = tempCanvas.width * crop.width; - const finalHeight = tempCanvas.height * crop.height; - finalCanvas.width = finalWidth; - finalCanvas.height = finalHeight; + const finalCanvas = document.createElement('canvas'); + const finalCtx = finalCanvas.getContext('2d'); + const crop = cropData[pageNum]; + const finalWidth = tempCanvas.width * crop.width; + const finalHeight = tempCanvas.height * crop.height; + finalCanvas.width = finalWidth; + finalCanvas.height = finalHeight; - finalCtx?.drawImage( - tempCanvas, - tempCanvas.width * crop.x, - tempCanvas.height * crop.y, - finalWidth, - finalHeight, - 0, 0, finalWidth, finalHeight - ); + finalCtx?.drawImage( + tempCanvas, + tempCanvas.width * crop.x, + tempCanvas.height * crop.y, + finalWidth, + finalHeight, + 0, + 0, + finalWidth, + finalHeight + ); - const pngBytes = await new Promise((res) => - finalCanvas.toBlob((blob) => blob?.arrayBuffer().then(res), 'image/jpeg', 0.9) - ); - const embeddedImage = await newPdfDoc.embedPng(pngBytes); - const newPage = newPdfDoc.addPage([finalWidth, finalHeight]); - newPage.drawImage(embeddedImage, { x: 0, y: 0, width: finalWidth, height: finalHeight }); - } else { - const [copiedPage] = await newPdfDoc.copyPages(sourcePdfDocForCopying, [i]); - newPdfDoc.addPage(copiedPage); - } + const pngBytes = await new Promise((res) => + finalCanvas.toBlob( + (blob) => blob?.arrayBuffer().then(res), + 'image/jpeg', + 0.9 + ) + ); + const embeddedImage = await newPdfDoc.embedPng(pngBytes); + const newPage = newPdfDoc.addPage([finalWidth, finalHeight]); + newPage.drawImage(embeddedImage, { + x: 0, + y: 0, + width: finalWidth, + height: finalHeight, + }); + } else { + const [copiedPage] = await newPdfDoc.copyPages(sourcePdfDocForCopying, [ + i, + ]); + newPdfDoc.addPage(copiedPage); } + } - return newPdfDoc.save(); + return newPdfDoc.save(); } function resetState() { - if (cropperState.cropper) { - cropperState.cropper.destroy(); - cropperState.cropper = null; - } + if (cropperState.cropper) { + cropperState.cropper.destroy(); + cropperState.cropper = null; + } - cropperState.pdfDoc = null; - cropperState.originalPdfBytes = null; - cropperState.pageCrops = {}; - cropperState.currentPageNum = 1; - cropperState.file = null; + cropperState.pdfDoc = null; + cropperState.originalPdfBytes = null; + cropperState.pageCrops = {}; + cropperState.currentPageNum = 1; + cropperState.file = null; - const cropperEditor = document.getElementById('cropper-editor'); - if (cropperEditor) cropperEditor.classList.add('hidden'); + const cropperEditor = document.getElementById('cropper-editor'); + if (cropperEditor) cropperEditor.classList.add('hidden'); - const container = document.getElementById('cropper-container'); - if (container) container.innerHTML = ''; + const container = document.getElementById('cropper-container'); + if (container) container.innerHTML = ''; - const fileDisplayArea = document.getElementById('file-display-area'); - if (fileDisplayArea) fileDisplayArea.innerHTML = ''; + const fileDisplayArea = document.getElementById('file-display-area'); + if (fileDisplayArea) fileDisplayArea.innerHTML = ''; - const cropBtn = document.getElementById('crop-button') as HTMLButtonElement; - if (cropBtn) cropBtn.disabled = true; + const cropBtn = document.getElementById('crop-button') as HTMLButtonElement; + if (cropBtn) cropBtn.disabled = true; } diff --git a/src/js/logic/decrypt-pdf-page.ts b/src/js/logic/decrypt-pdf-page.ts index af291fd..4e93018 100644 --- a/src/js/logic/decrypt-pdf-page.ts +++ b/src/js/logic/decrypt-pdf-page.ts @@ -1,236 +1,358 @@ import { showAlert } from '../ui.js'; -import { downloadFile, formatBytes, initializeQpdf, readFileAsArrayBuffer } from '../utils/helpers.js'; +import { + downloadFile, + formatBytes, + initializeQpdf, + readFileAsArrayBuffer, +} from '../utils/helpers.js'; import { icons, createIcons } from 'lucide'; +import JSZip from 'jszip'; import { DecryptPdfState } from '@/types'; const pageState: DecryptPdfState = { - file: null, + files: [], }; function resetState() { - pageState.file = null; + pageState.files = []; - const fileDisplayArea = document.getElementById('file-display-area'); - if (fileDisplayArea) fileDisplayArea.innerHTML = ''; + const fileDisplayArea = document.getElementById('file-display-area'); + if (fileDisplayArea) fileDisplayArea.innerHTML = ''; - const toolOptions = document.getElementById('tool-options'); - if (toolOptions) toolOptions.classList.add('hidden'); + const toolOptions = document.getElementById('tool-options'); + if (toolOptions) toolOptions.classList.add('hidden'); - const fileInput = document.getElementById('file-input') as HTMLInputElement; - if (fileInput) fileInput.value = ''; + const fileControls = document.getElementById('file-controls'); + if (fileControls) fileControls.classList.add('hidden'); - const passwordInput = document.getElementById('password-input') as HTMLInputElement; - if (passwordInput) passwordInput.value = ''; + const fileInput = document.getElementById('file-input') as HTMLInputElement; + if (fileInput) fileInput.value = ''; + + const passwordInput = document.getElementById( + 'password-input' + ) as HTMLInputElement; + if (passwordInput) passwordInput.value = ''; } async function updateUI() { - const fileDisplayArea = document.getElementById('file-display-area'); - const toolOptions = document.getElementById('tool-options'); + const fileDisplayArea = document.getElementById('file-display-area'); + const toolOptions = document.getElementById('tool-options'); + const fileControls = document.getElementById('file-controls'); - if (!fileDisplayArea) return; + if (!fileDisplayArea) return; - fileDisplayArea.innerHTML = ''; + fileDisplayArea.innerHTML = ''; - if (pageState.file) { - const fileDiv = document.createElement('div'); - fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm'; + if (pageState.files.length > 0) { + pageState.files.forEach((file, index) => { + const fileDiv = document.createElement('div'); + fileDiv.className = + 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm'; - const infoContainer = document.createElement('div'); - infoContainer.className = 'flex flex-col overflow-hidden'; + const infoContainer = document.createElement('div'); + infoContainer.className = 'flex flex-col overflow-hidden'; - const nameSpan = document.createElement('div'); - nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1'; - nameSpan.textContent = pageState.file.name; + const nameSpan = document.createElement('div'); + nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1'; + nameSpan.textContent = file.name; - const metaSpan = document.createElement('div'); - metaSpan.className = 'text-xs text-gray-400'; - metaSpan.textContent = formatBytes(pageState.file.size); + const metaSpan = document.createElement('div'); + metaSpan.className = 'text-xs text-gray-400'; + metaSpan.textContent = formatBytes(file.size); - infoContainer.append(nameSpan, metaSpan); + infoContainer.append(nameSpan, metaSpan); - const removeBtn = document.createElement('button'); - removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0'; - removeBtn.innerHTML = ''; - removeBtn.onclick = function () { - resetState(); - }; + const removeBtn = document.createElement('button'); + removeBtn.className = + 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0'; + removeBtn.innerHTML = ''; + removeBtn.onclick = function () { + pageState.files.splice(index, 1); + updateUI(); + }; - fileDiv.append(infoContainer, removeBtn); - fileDisplayArea.appendChild(fileDiv); - createIcons({ icons }); + fileDiv.append(infoContainer, removeBtn); + fileDisplayArea.appendChild(fileDiv); + }); - if (toolOptions) toolOptions.classList.remove('hidden'); - } else { - if (toolOptions) toolOptions.classList.add('hidden'); - } + createIcons({ icons }); + + if (toolOptions) toolOptions.classList.remove('hidden'); + if (fileControls) fileControls.classList.remove('hidden'); + } else { + if (toolOptions) toolOptions.classList.add('hidden'); + if (fileControls) fileControls.classList.add('hidden'); + } } function handleFileSelect(files: FileList | null) { - if (files && files.length > 0) { - const file = files[0]; - if (file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf')) { - pageState.file = file; - updateUI(); - } + if (files && files.length > 0) { + const pdfFiles = Array.from(files).filter( + (f) => + f.type === 'application/pdf' || f.name.toLowerCase().endsWith('.pdf') + ); + if (pdfFiles.length > 0) { + pageState.files.push(...pdfFiles); + updateUI(); } + } } async function decryptPdf() { - if (!pageState.file) { - showAlert('No File', 'Please upload a PDF file first.'); - return; - } + if (pageState.files.length === 0) { + showAlert('No File', 'Please upload at least one PDF file.'); + return; + } - const password = (document.getElementById('password-input') as HTMLInputElement)?.value; + const password = ( + document.getElementById('password-input') as HTMLInputElement + )?.value; - if (!password) { - showAlert('Input Required', 'Please enter the PDF password.'); - return; - } + if (!password) { + showAlert('Input Required', 'Please enter the PDF password.'); + return; + } - const inputPath = '/input.pdf'; - const outputPath = '/output.pdf'; - let qpdf: any; + const loaderModal = document.getElementById('loader-modal'); + const loaderText = document.getElementById('loader-text'); + let qpdf: any; - const loaderModal = document.getElementById('loader-modal'); - const loaderText = document.getElementById('loader-text'); + try { + if (loaderModal) loaderModal.classList.remove('hidden'); + if (loaderText) loaderText.textContent = 'Initializing decryption...'; - try { - if (loaderModal) loaderModal.classList.remove('hidden'); - if (loaderText) loaderText.textContent = 'Initializing decryption...'; + qpdf = await initializeQpdf(); - qpdf = await initializeQpdf(); + if (pageState.files.length === 1) { + // Single file: decrypt and download directly + const file = pageState.files[0]; + const inputPath = '/input.pdf'; + const outputPath = '/output.pdf'; + try { if (loaderText) loaderText.textContent = 'Reading encrypted PDF...'; - const fileBuffer = await readFileAsArrayBuffer(pageState.file); + const fileBuffer = await readFileAsArrayBuffer(file); const uint8Array = new Uint8Array(fileBuffer as ArrayBuffer); - qpdf.FS.writeFile(inputPath, uint8Array); if (loaderText) loaderText.textContent = 'Decrypting PDF...'; - - const args = [inputPath, '--password=' + password, '--decrypt', outputPath]; + const args = [ + inputPath, + '--password=' + password, + '--decrypt', + outputPath, + ]; try { - qpdf.callMain(args); + qpdf.callMain(args); } catch (qpdfError: any) { - console.error('qpdf execution error:', qpdfError); - - if ( - qpdfError.message?.includes('invalid password') || - qpdfError.message?.includes('password') - ) { - throw new Error('INVALID_PASSWORD'); - } - throw qpdfError; + if ( + qpdfError.message?.includes('invalid password') || + qpdfError.message?.includes('password') + ) { + throw new Error('INVALID_PASSWORD'); + } + throw qpdfError; } if (loaderText) loaderText.textContent = 'Preparing download...'; const outputFile = qpdf.FS.readFile(outputPath, { encoding: 'binary' }); if (outputFile.length === 0) { - throw new Error('Decryption resulted in an empty file.'); + throw new Error('Decryption resulted in an empty file.'); } - const blob = new Blob([outputFile], { type: 'application/pdf' }); - downloadFile(blob, `unlocked-${pageState.file.name}`); + const blob = new Blob([new Uint8Array(outputFile)], { + type: 'application/pdf', + }); + downloadFile(blob, `unlocked-${file.name}`); if (loaderModal) loaderModal.classList.add('hidden'); showAlert( - 'Success', - 'PDF decrypted successfully! Your download has started.', - 'success', - () => { resetState(); } + 'Success', + 'PDF decrypted successfully! Your download has started.', + 'success', + () => { + resetState(); + } ); - } catch (error: any) { - console.error('Error during PDF decryption:', error); - if (loaderModal) loaderModal.classList.add('hidden'); - - if (error.message === 'INVALID_PASSWORD') { - showAlert( - 'Incorrect Password', - 'The password you entered is incorrect. Please try again.' - ); - } else if (error.message?.includes('password')) { - showAlert( - 'Password Error', - 'Unable to decrypt the PDF with the provided password.' - ); - } else { - showAlert( - 'Decryption Failed', - `An error occurred: ${error.message || 'The password you entered is wrong or the file is corrupted.'}` - ); - } - } finally { + } finally { try { - if (qpdf?.FS) { - try { - qpdf.FS.unlink(inputPath); - } catch (e) { - console.warn('Failed to unlink input file:', e); - } - try { - qpdf.FS.unlink(outputPath); - } catch (e) { - console.warn('Failed to unlink output file:', e); - } - } + if (qpdf?.FS) { + if (qpdf.FS.analyzePath(inputPath).exists) + qpdf.FS.unlink(inputPath); + if (qpdf.FS.analyzePath(outputPath).exists) + qpdf.FS.unlink(outputPath); + } } catch (cleanupError) { - console.warn('Failed to cleanup WASM FS:', cleanupError); + console.warn('Failed to cleanup WASM FS:', cleanupError); } + } + } else { + // Multiple files: decrypt all and download as ZIP + const zip = new JSZip(); + let successCount = 0; + let errorCount = 0; + + for (let i = 0; i < pageState.files.length; i++) { + const file = pageState.files[i]; + const inputPath = `/input_${i}.pdf`; + const outputPath = `/output_${i}.pdf`; + + if (loaderText) + loaderText.textContent = `Decrypting ${file.name} (${i + 1}/${pageState.files.length})...`; + + try { + const fileBuffer = await readFileAsArrayBuffer(file); + const uint8Array = new Uint8Array(fileBuffer as ArrayBuffer); + qpdf.FS.writeFile(inputPath, uint8Array); + + const args = [ + inputPath, + '--password=' + password, + '--decrypt', + outputPath, + ]; + + try { + qpdf.callMain(args); + } catch (qpdfError: any) { + if ( + qpdfError.message?.includes('invalid password') || + qpdfError.message?.includes('password') + ) { + throw new Error(`Invalid password for ${file.name}`); + } + throw qpdfError; + } + + const outputFile = qpdf.FS.readFile(outputPath, { + encoding: 'binary', + }); + if (!outputFile || outputFile.length === 0) { + throw new Error( + `Decryption resulted in an empty file for ${file.name}.` + ); + } + + zip.file(`unlocked-${file.name}`, outputFile, { binary: true }); + successCount++; + } catch (fileError: any) { + errorCount++; + console.error(`Failed to decrypt ${file.name}:`, fileError); + } finally { + try { + if (qpdf?.FS) { + if (qpdf.FS.analyzePath(inputPath).exists) + qpdf.FS.unlink(inputPath); + if (qpdf.FS.analyzePath(outputPath).exists) + qpdf.FS.unlink(outputPath); + } + } catch (cleanupError) { + console.warn( + `Failed to cleanup WASM FS for ${file.name}:`, + cleanupError + ); + } + } + } + + if (successCount === 0) { + throw new Error( + 'No PDF files could be decrypted. The password may be incorrect.' + ); + } + + if (loaderText) loaderText.textContent = 'Generating ZIP file...'; + const zipBlob = await zip.generateAsync({ type: 'blob' }); + downloadFile(zipBlob, 'decrypted-pdfs.zip'); + + let alertMessage = `${successCount} PDF(s) decrypted successfully.`; + if (errorCount > 0) { + alertMessage += ` ${errorCount} file(s) failed.`; + } + showAlert('Processing Complete', alertMessage, 'success', () => { + resetState(); + }); } + } catch (error: any) { + console.error('Error during PDF decryption:', error); + + if (error.message === 'INVALID_PASSWORD') { + showAlert( + 'Incorrect Password', + 'The password you entered is incorrect. Please try again.' + ); + } else if (error.message?.includes('password')) { + showAlert( + 'Password Error', + 'Unable to decrypt the PDF with the provided password.' + ); + } else { + showAlert( + 'Decryption Failed', + `An error occurred: ${error.message || 'The password you entered is wrong or the file is corrupted.'}` + ); + } + } finally { + if (loaderModal) loaderModal.classList.add('hidden'); + } } document.addEventListener('DOMContentLoaded', function () { - const fileInput = document.getElementById('file-input') as HTMLInputElement; - const dropZone = document.getElementById('drop-zone'); - const processBtn = document.getElementById('process-btn'); - const backBtn = document.getElementById('back-to-tools'); + const fileInput = document.getElementById('file-input') as HTMLInputElement; + const dropZone = document.getElementById('drop-zone'); + const processBtn = document.getElementById('process-btn'); + const addMoreBtn = document.getElementById('add-more-btn'); + const clearFilesBtn = document.getElementById('clear-files-btn'); + const backBtn = document.getElementById('back-to-tools'); - if (backBtn) { - backBtn.addEventListener('click', function () { - window.location.href = import.meta.env.BASE_URL; - }); - } + if (backBtn) { + backBtn.addEventListener('click', function () { + window.location.href = import.meta.env.BASE_URL; + }); + } - if (fileInput && dropZone) { - fileInput.addEventListener('change', function (e) { - handleFileSelect((e.target as HTMLInputElement).files); - }); + if (fileInput && dropZone) { + fileInput.addEventListener('change', function (e) { + handleFileSelect((e.target as HTMLInputElement).files); + }); - dropZone.addEventListener('dragover', function (e) { - e.preventDefault(); - dropZone.classList.add('bg-gray-700'); - }); + dropZone.addEventListener('dragover', function (e) { + e.preventDefault(); + dropZone.classList.add('bg-gray-700'); + }); - dropZone.addEventListener('dragleave', function (e) { - e.preventDefault(); - dropZone.classList.remove('bg-gray-700'); - }); + dropZone.addEventListener('dragleave', function (e) { + e.preventDefault(); + dropZone.classList.remove('bg-gray-700'); + }); - dropZone.addEventListener('drop', function (e) { - e.preventDefault(); - dropZone.classList.remove('bg-gray-700'); - const files = e.dataTransfer?.files; - if (files && files.length > 0) { - const pdfFiles = Array.from(files).filter(function (f) { - return f.type === 'application/pdf' || f.name.toLowerCase().endsWith('.pdf'); - }); - if (pdfFiles.length > 0) { - const dataTransfer = new DataTransfer(); - dataTransfer.items.add(pdfFiles[0]); - handleFileSelect(dataTransfer.files); - } - } - }); + dropZone.addEventListener('drop', function (e) { + e.preventDefault(); + dropZone.classList.remove('bg-gray-700'); + handleFileSelect(e.dataTransfer?.files); + }); - fileInput.addEventListener('click', function () { - fileInput.value = ''; - }); - } + fileInput.addEventListener('click', function () { + fileInput.value = ''; + }); + } - if (processBtn) { - processBtn.addEventListener('click', decryptPdf); - } + if (processBtn) { + processBtn.addEventListener('click', decryptPdf); + } + + if (addMoreBtn) { + addMoreBtn.addEventListener('click', function () { + fileInput.value = ''; + fileInput.click(); + }); + } + + if (clearFilesBtn) { + clearFilesBtn.addEventListener('click', function () { + resetState(); + }); + } }); diff --git a/src/js/logic/delete-pages-page.ts b/src/js/logic/delete-pages-page.ts index 04731ad..68c576b 100644 --- a/src/js/logic/delete-pages-page.ts +++ b/src/js/logic/delete-pages-page.ts @@ -1,267 +1,309 @@ import { createIcons, icons } from 'lucide'; import { showAlert, showLoader, hideLoader } from '../ui.js'; -import { readFileAsArrayBuffer, formatBytes, downloadFile, getPDFDocument, parsePageRanges } from '../utils/helpers.js'; +import { + readFileAsArrayBuffer, + formatBytes, + downloadFile, + getPDFDocument, + parsePageRanges, +} from '../utils/helpers.js'; import { PDFDocument } from 'pdf-lib'; +import { deletePdfPages } from '../utils/pdf-operations.js'; import * as pdfjsLib from 'pdfjs-dist'; import { DeletePagesState } from '@/types'; -pdfjsLib.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).toString(); +pdfjsLib.GlobalWorkerOptions.workerSrc = new URL( + 'pdfjs-dist/build/pdf.worker.min.mjs', + import.meta.url +).toString(); const deleteState: DeletePagesState = { - file: null, - pdfDoc: null, - pdfJsDoc: null, - totalPages: 0, - pagesToDelete: new Set(), + file: null, + pdfDoc: null, + pdfJsDoc: null, + totalPages: 0, + pagesToDelete: new Set(), }; if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', initializePage); + document.addEventListener('DOMContentLoaded', initializePage); } else { - initializePage(); + initializePage(); } function initializePage() { - createIcons({ icons }); + createIcons({ icons }); - const fileInput = document.getElementById('file-input') as HTMLInputElement; - const dropZone = document.getElementById('drop-zone'); - const processBtn = document.getElementById('process-btn'); - const pagesInput = document.getElementById('pages-to-delete') as HTMLInputElement; + const fileInput = document.getElementById('file-input') as HTMLInputElement; + const dropZone = document.getElementById('drop-zone'); + const processBtn = document.getElementById('process-btn'); + const pagesInput = document.getElementById( + 'pages-to-delete' + ) as HTMLInputElement; - if (fileInput) fileInput.addEventListener('change', handleFileUpload); + if (fileInput) fileInput.addEventListener('change', handleFileUpload); - 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]); - }); - // Clear value on click to allow re-selecting the same file - fileInput?.addEventListener('click', () => { - if (fileInput) fileInput.value = ''; - }); - } - - if (processBtn) processBtn.addEventListener('click', deletePages); - if (pagesInput) pagesInput.addEventListener('input', updatePreview); - - document.getElementById('back-to-tools')?.addEventListener('click', () => { - window.location.href = import.meta.env.BASE_URL; + 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]); + }); + // Clear value on click to allow re-selecting the same file + fileInput?.addEventListener('click', () => { + if (fileInput) fileInput.value = ''; + }); + } + + if (processBtn) processBtn.addEventListener('click', deletePages); + if (pagesInput) pagesInput.addEventListener('input', updatePreview); + + document.getElementById('back-to-tools')?.addEventListener('click', () => { + window.location.href = import.meta.env.BASE_URL; + }); } function handleFileUpload(e: Event) { - const input = e.target as HTMLInputElement; - if (input.files && input.files.length > 0) handleFile(input.files[0]); + const input = e.target as HTMLInputElement; + if (input.files && input.files.length > 0) handleFile(input.files[0]); } async function handleFile(file: File) { - if (file.type !== 'application/pdf' && !file.name.toLowerCase().endsWith('.pdf')) { - showAlert('Invalid File', 'Please select a PDF file.'); - return; - } + if ( + file.type !== 'application/pdf' && + !file.name.toLowerCase().endsWith('.pdf') + ) { + showAlert('Invalid File', 'Please select a PDF file.'); + return; + } - showLoader('Loading PDF...'); - deleteState.file = file; + showLoader('Loading PDF...'); + deleteState.file = file; - try { - const arrayBuffer = await readFileAsArrayBuffer(file); - deleteState.pdfDoc = await PDFDocument.load(arrayBuffer as ArrayBuffer, { ignoreEncryption: true, throwOnInvalidObject: false }); - deleteState.pdfJsDoc = await getPDFDocument({ data: (arrayBuffer as ArrayBuffer).slice(0) }).promise; - deleteState.totalPages = deleteState.pdfDoc.getPageCount(); - deleteState.pagesToDelete = new Set(); + try { + const arrayBuffer = await readFileAsArrayBuffer(file); + deleteState.pdfDoc = await PDFDocument.load(arrayBuffer as ArrayBuffer, { + ignoreEncryption: true, + throwOnInvalidObject: false, + }); + deleteState.pdfJsDoc = await getPDFDocument({ + data: (arrayBuffer as ArrayBuffer).slice(0), + }).promise; + deleteState.totalPages = deleteState.pdfDoc.getPageCount(); + deleteState.pagesToDelete = new Set(); - updateFileDisplay(); - showOptions(); - await renderThumbnails(); - hideLoader(); - } catch (error) { - console.error('Error loading PDF:', error); - hideLoader(); - showAlert('Error', 'Failed to load PDF file.'); - } + updateFileDisplay(); + showOptions(); + await renderThumbnails(); + hideLoader(); + } catch (error) { + console.error('Error loading PDF:', error); + hideLoader(); + showAlert('Error', 'Failed to load PDF file.'); + } } function updateFileDisplay() { - const fileDisplayArea = document.getElementById('file-display-area'); - if (!fileDisplayArea || !deleteState.file) return; + const fileDisplayArea = document.getElementById('file-display-area'); + if (!fileDisplayArea || !deleteState.file) return; - fileDisplayArea.innerHTML = ''; - const fileDiv = document.createElement('div'); - fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg'; + 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 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 = deleteState.file.name; + const nameSpan = document.createElement('div'); + nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1'; + nameSpan.textContent = deleteState.file.name; - const metaSpan = document.createElement('div'); - metaSpan.className = 'text-xs text-gray-400'; - metaSpan.textContent = `${formatBytes(deleteState.file.size)} • ${deleteState.totalPages} pages`; + const metaSpan = document.createElement('div'); + metaSpan.className = 'text-xs text-gray-400'; + metaSpan.textContent = `${formatBytes(deleteState.file.size)} • ${deleteState.totalPages} pages`; - infoContainer.append(nameSpan, metaSpan); + infoContainer.append(nameSpan, metaSpan); - const removeBtn = document.createElement('button'); - removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0'; - removeBtn.innerHTML = ''; - removeBtn.onclick = () => resetState(); + 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 }); + fileDiv.append(infoContainer, removeBtn); + fileDisplayArea.appendChild(fileDiv); + createIcons({ icons }); } function showOptions() { - const deleteOptions = document.getElementById('delete-options'); - const totalPagesSpan = document.getElementById('total-pages'); + const deleteOptions = document.getElementById('delete-options'); + const totalPagesSpan = document.getElementById('total-pages'); - if (deleteOptions) deleteOptions.classList.remove('hidden'); - if (totalPagesSpan) totalPagesSpan.textContent = deleteState.totalPages.toString(); + if (deleteOptions) deleteOptions.classList.remove('hidden'); + if (totalPagesSpan) + totalPagesSpan.textContent = deleteState.totalPages.toString(); } async function renderThumbnails() { - const container = document.getElementById('delete-pages-preview'); - if (!container) return; - container.innerHTML = ''; + const container = document.getElementById('delete-pages-preview'); + if (!container) return; + container.innerHTML = ''; - for (let i = 1; i <= deleteState.totalPages; i++) { - const page = await deleteState.pdfJsDoc.getPage(i); - const viewport = page.getViewport({ scale: 0.3 }); + for (let i = 1; i <= deleteState.totalPages; i++) { + const page = await deleteState.pdfJsDoc.getPage(i); + const viewport = page.getViewport({ scale: 1 }); - const canvas = document.createElement('canvas'); - canvas.width = viewport.width; - canvas.height = viewport.height; - const ctx = canvas.getContext('2d'); - await page.render({ canvasContext: ctx, viewport }).promise; + const canvas = document.createElement('canvas'); + canvas.width = viewport.width; + canvas.height = viewport.height; + const ctx = canvas.getContext('2d'); + await page.render({ canvasContext: ctx, viewport }).promise; - const wrapper = document.createElement('div'); - wrapper.className = 'relative cursor-pointer group'; - wrapper.dataset.page = i.toString(); + const wrapper = document.createElement('div'); + wrapper.className = 'relative cursor-pointer group'; + wrapper.dataset.page = i.toString(); - const imgContainer = document.createElement('div'); - imgContainer.className = 'w-full h-28 bg-gray-900 rounded-lg flex items-center justify-center overflow-hidden border-2 border-gray-600'; + const imgContainer = document.createElement('div'); + imgContainer.className = + 'w-full h-28 bg-gray-900 rounded-lg flex items-center justify-center overflow-hidden border-2 border-gray-600'; - const img = document.createElement('img'); - img.src = canvas.toDataURL(); - img.className = 'max-w-full max-h-full object-contain'; + const img = document.createElement('img'); + img.src = canvas.toDataURL(); + img.className = 'max-w-full max-h-full object-contain'; - const pageLabel = document.createElement('span'); - pageLabel.className = 'absolute top-1 left-1 bg-gray-800 text-white text-xs px-1.5 py-0.5 rounded'; - pageLabel.textContent = `${i}`; + const pageLabel = document.createElement('span'); + pageLabel.className = + 'absolute top-1 left-1 bg-gray-800 text-white text-xs px-1.5 py-0.5 rounded'; + pageLabel.textContent = `${i}`; - const deleteOverlay = document.createElement('div'); - deleteOverlay.className = 'absolute inset-0 bg-red-500/50 hidden items-center justify-center rounded-lg'; - deleteOverlay.innerHTML = ''; + const deleteOverlay = document.createElement('div'); + deleteOverlay.className = + 'absolute inset-0 bg-red-500/50 hidden items-center justify-center rounded-lg'; + deleteOverlay.innerHTML = + ''; - imgContainer.appendChild(img); - wrapper.append(imgContainer, pageLabel, deleteOverlay); - container.appendChild(wrapper); + imgContainer.appendChild(img); + wrapper.append(imgContainer, pageLabel, deleteOverlay); + container.appendChild(wrapper); - wrapper.addEventListener('click', () => togglePageDelete(i, wrapper)); - } - createIcons({ icons }); + wrapper.addEventListener('click', () => togglePageDelete(i, wrapper)); + } + createIcons({ icons }); } function togglePageDelete(pageNum: number, wrapper: HTMLElement) { - const overlay = wrapper.querySelector('.bg-red-500\\/50'); - if (deleteState.pagesToDelete.has(pageNum)) { - deleteState.pagesToDelete.delete(pageNum); - overlay?.classList.add('hidden'); - overlay?.classList.remove('flex'); - } else { - deleteState.pagesToDelete.add(pageNum); - overlay?.classList.remove('hidden'); - overlay?.classList.add('flex'); - } - updateInputFromSelection(); + const overlay = wrapper.querySelector('.bg-red-500\\/50'); + if (deleteState.pagesToDelete.has(pageNum)) { + deleteState.pagesToDelete.delete(pageNum); + overlay?.classList.add('hidden'); + overlay?.classList.remove('flex'); + } else { + deleteState.pagesToDelete.add(pageNum); + overlay?.classList.remove('hidden'); + overlay?.classList.add('flex'); + } + updateInputFromSelection(); } function updateInputFromSelection() { - const pagesInput = document.getElementById('pages-to-delete') as HTMLInputElement; - if (pagesInput) { - const sorted = Array.from(deleteState.pagesToDelete).sort((a, b) => a - b); - pagesInput.value = sorted.join(', '); - } + const pagesInput = document.getElementById( + 'pages-to-delete' + ) as HTMLInputElement; + if (pagesInput) { + const sorted = Array.from(deleteState.pagesToDelete).sort((a, b) => a - b); + pagesInput.value = sorted.join(', '); + } } function updatePreview() { - const pagesInput = document.getElementById('pages-to-delete') as HTMLInputElement; - if (!pagesInput) return; + const pagesInput = document.getElementById( + 'pages-to-delete' + ) as HTMLInputElement; + if (!pagesInput) return; - deleteState.pagesToDelete = new Set(parsePageRanges(pagesInput.value, deleteState.totalPages).map(i => i + 1)); + deleteState.pagesToDelete = new Set( + parsePageRanges(pagesInput.value, deleteState.totalPages).map((i) => i + 1) + ); - const container = document.getElementById('delete-pages-preview'); - if (!container) return; + const container = document.getElementById('delete-pages-preview'); + if (!container) return; - container.querySelectorAll('[data-page]').forEach((wrapper) => { - const pageNum = parseInt((wrapper as HTMLElement).dataset.page || '0', 10); - const overlay = wrapper.querySelector('.bg-red-500\\/50'); - if (deleteState.pagesToDelete.has(pageNum)) { - overlay?.classList.remove('hidden'); - overlay?.classList.add('flex'); - } else { - overlay?.classList.add('hidden'); - overlay?.classList.remove('flex'); - } - }); + container.querySelectorAll('[data-page]').forEach((wrapper) => { + const pageNum = parseInt((wrapper as HTMLElement).dataset.page || '0', 10); + const overlay = wrapper.querySelector('.bg-red-500\\/50'); + if (deleteState.pagesToDelete.has(pageNum)) { + overlay?.classList.remove('hidden'); + overlay?.classList.add('flex'); + } else { + overlay?.classList.add('hidden'); + overlay?.classList.remove('flex'); + } + }); } - - async function deletePages() { - if (deleteState.pagesToDelete.size === 0) { - showAlert('No Pages', 'Please select pages to delete.'); - return; - } + if (deleteState.pagesToDelete.size === 0) { + showAlert('No Pages', 'Please select pages to delete.'); + return; + } - if (deleteState.pagesToDelete.size >= deleteState.totalPages) { - showAlert('Error', 'Cannot delete all pages.'); - return; - } + if (deleteState.pagesToDelete.size >= deleteState.totalPages) { + showAlert('Error', 'Cannot delete all pages.'); + return; + } - showLoader('Deleting pages...'); + showLoader('Deleting pages...'); - try { - const pagesToKeep = []; - for (let i = 0; i < deleteState.totalPages; i++) { - if (!deleteState.pagesToDelete.has(i + 1)) pagesToKeep.push(i); - } + try { + const srcBytes = await deleteState.pdfDoc.save(); + const resultBytes = await deletePdfPages( + new Uint8Array(srcBytes), + deleteState.pagesToDelete + ); + const baseName = deleteState.file?.name.replace('.pdf', '') || 'document'; + downloadFile( + new Blob([resultBytes as unknown as BlobPart], { + type: 'application/pdf', + }), + `${baseName}_pages_removed.pdf` + ); - const newPdf = await PDFDocument.create(); - const copiedPages = await newPdf.copyPages(deleteState.pdfDoc, pagesToKeep); - copiedPages.forEach(page => newPdf.addPage(page)); - - const pdfBytes = await newPdf.save(); - const baseName = deleteState.file?.name.replace('.pdf', '') || 'document'; - downloadFile(new Blob([pdfBytes as BlobPart], { type: 'application/pdf' }), `${baseName}_pages_removed.pdf`); - - hideLoader(); - showAlert('Success', `Deleted ${deleteState.pagesToDelete.size} page(s) successfully!`, 'success', () => resetState()); - } catch (error) { - console.error('Error deleting pages:', error); - hideLoader(); - showAlert('Error', 'Failed to delete pages.'); - } + hideLoader(); + showAlert( + 'Success', + `Deleted ${deleteState.pagesToDelete.size} page(s) successfully!`, + 'success', + () => resetState() + ); + } catch (error) { + console.error('Error deleting pages:', error); + hideLoader(); + showAlert('Error', 'Failed to delete pages.'); + } } function resetState() { - deleteState.file = null; - deleteState.pdfDoc = null; - deleteState.pdfJsDoc = null; - deleteState.totalPages = 0; - deleteState.pagesToDelete = new Set(); + deleteState.file = null; + deleteState.pdfDoc = null; + deleteState.pdfJsDoc = null; + deleteState.totalPages = 0; + deleteState.pagesToDelete = new Set(); - document.getElementById('delete-options')?.classList.add('hidden'); - const fileDisplayArea = document.getElementById('file-display-area'); - if (fileDisplayArea) fileDisplayArea.innerHTML = ''; - const pagesInput = document.getElementById('pages-to-delete') as HTMLInputElement; - if (pagesInput) pagesInput.value = ''; - const container = document.getElementById('delete-pages-preview'); - if (container) container.innerHTML = ''; + document.getElementById('delete-options')?.classList.add('hidden'); + const fileDisplayArea = document.getElementById('file-display-area'); + if (fileDisplayArea) fileDisplayArea.innerHTML = ''; + const pagesInput = document.getElementById( + 'pages-to-delete' + ) as HTMLInputElement; + if (pagesInput) pagesInput.value = ''; + const container = document.getElementById('delete-pages-preview'); + if (container) container.innerHTML = ''; } diff --git a/src/js/logic/deskew-pdf-page.ts b/src/js/logic/deskew-pdf-page.ts index e86a67e..d609a25 100644 --- a/src/js/logic/deskew-pdf-page.ts +++ b/src/js/logic/deskew-pdf-page.ts @@ -1,4 +1,6 @@ -import { PyMuPDF } from '@bentopdf/pymupdf-wasm'; +import { isWasmAvailable, getWasmBaseUrl } from '../config/wasm-cdn-config.js'; +import { showWasmRequiredDialog } from '../utils/wasm-provider.js'; +import { loadPyMuPDF, isPyMuPDFAvailable } from '../utils/pymupdf-loader.js'; import { createIcons, icons } from 'lucide'; import { downloadFile } from '../utils/helpers'; @@ -10,13 +12,11 @@ interface DeskewResult { } let selectedFiles: File[] = []; -let pymupdf: PyMuPDF | null = null; +let pymupdf: any = null; -function initPyMuPDF(): PyMuPDF { +async function initPyMuPDF(): Promise { if (!pymupdf) { - pymupdf = new PyMuPDF({ - assetPath: import.meta.env.BASE_URL + 'pymupdf-wasm/', - }); + pymupdf = await loadPyMuPDF(); } return pymupdf; } @@ -137,6 +137,12 @@ async function processDeskew(): Promise { return; } + // Check if PyMuPDF is configured + if (!isWasmAvailable('pymupdf')) { + showWasmRequiredDialog('pymupdf'); + return; + } + const thresholdSelect = document.getElementById( 'deskew-threshold' ) as HTMLSelectElement; @@ -148,7 +154,7 @@ async function processDeskew(): Promise { showLoader('Initializing PyMuPDF...'); try { - const pdf = initPyMuPDF(); + const pdf = await initPyMuPDF(); await pdf.load(); for (const file of selectedFiles) { diff --git a/src/js/logic/digital-sign-pdf.ts b/src/js/logic/digital-sign-pdf.ts index a626ed9..37bc989 100644 --- a/src/js/logic/digital-sign-pdf.ts +++ b/src/js/logic/digital-sign-pdf.ts @@ -2,82 +2,94 @@ import { PdfSigner, type SignOption } from 'zgapdfsigner'; import forge from 'node-forge'; import { CertificateData, SignPdfOptions } from '@/types'; -export function parsePfxFile(pfxBytes: ArrayBuffer, password: string): CertificateData { - const pfxAsn1 = forge.asn1.fromDer(forge.util.createBuffer(new Uint8Array(pfxBytes))); - const pfx = forge.pkcs12.pkcs12FromAsn1(pfxAsn1, password); +export function parsePfxFile( + pfxBytes: ArrayBuffer, + password: string +): CertificateData { + const pfxAsn1 = forge.asn1.fromDer( + forge.util.createBuffer(new Uint8Array(pfxBytes)) + ); + const pfx = forge.pkcs12.pkcs12FromAsn1(pfxAsn1, password); - const certBags = pfx.getBags({ bagType: forge.pki.oids.certBag }); - const keyBags = pfx.getBags({ bagType: forge.pki.oids.pkcs8ShroudedKeyBag }); + const certBags = pfx.getBags({ bagType: forge.pki.oids.certBag }); + const keyBags = pfx.getBags({ bagType: forge.pki.oids.pkcs8ShroudedKeyBag }); - const certBagArray = certBags[forge.pki.oids.certBag]; - const keyBagArray = keyBags[forge.pki.oids.pkcs8ShroudedKeyBag]; + const certBagArray = certBags[forge.pki.oids.certBag]; + const keyBagArray = keyBags[forge.pki.oids.pkcs8ShroudedKeyBag]; - if (!certBagArray || certBagArray.length === 0) { - throw new Error('No certificate found in PFX file'); - } + if (!certBagArray || certBagArray.length === 0) { + throw new Error('No certificate found in PFX file'); + } - if (!keyBagArray || keyBagArray.length === 0) { - throw new Error('No private key found in PFX file'); - } + if (!keyBagArray || keyBagArray.length === 0) { + throw new Error('No private key found in PFX file'); + } - const certificate = certBagArray[0].cert; + const certificate = certBagArray[0].cert; - if (!certificate) { - throw new Error('Failed to extract certificate from PFX file'); - } + if (!certificate) { + throw new Error('Failed to extract certificate from PFX file'); + } - return { p12Buffer: pfxBytes, password, certificate }; + return { p12Buffer: pfxBytes, password, certificate }; } export function parsePemFiles( - certPem: string, - keyPem: string, - keyPassword?: string + certPem: string, + keyPem: string, + keyPassword?: string ): CertificateData { - const certificate = forge.pki.certificateFromPem(certPem); + const certificate = forge.pki.certificateFromPem(certPem); - let privateKey: forge.pki.PrivateKey; - if (keyPem.includes('ENCRYPTED')) { - if (!keyPassword) { - throw new Error('Password required for encrypted private key'); - } - privateKey = forge.pki.decryptRsaPrivateKey(keyPem, keyPassword); - if (!privateKey) { - throw new Error('Failed to decrypt private key'); - } - } else { - privateKey = forge.pki.privateKeyFromPem(keyPem); + let privateKey: forge.pki.PrivateKey; + if (keyPem.includes('ENCRYPTED')) { + if (!keyPassword) { + throw new Error('Password required for encrypted private key'); } - - const p12Password = keyPassword || 'temp-password'; - const p12Asn1 = forge.pkcs12.toPkcs12Asn1( - privateKey, - [certificate], - p12Password, - { algorithm: '3des' } - ); - const p12Der = forge.asn1.toDer(p12Asn1).getBytes(); - const p12Buffer = new Uint8Array(p12Der.length); - for (let i = 0; i < p12Der.length; i++) { - p12Buffer[i] = p12Der.charCodeAt(i); + privateKey = forge.pki.decryptRsaPrivateKey(keyPem, keyPassword); + if (!privateKey) { + throw new Error('Failed to decrypt private key'); } + } else { + privateKey = forge.pki.privateKeyFromPem(keyPem); + } - return { p12Buffer: p12Buffer.buffer, password: p12Password, certificate }; + const p12Password = keyPassword || crypto.randomUUID(); + const p12Asn1 = forge.pkcs12.toPkcs12Asn1( + privateKey, + [certificate], + p12Password, + { algorithm: '3des' } + ); + const p12Der = forge.asn1.toDer(p12Asn1).getBytes(); + const p12Buffer = new Uint8Array(p12Der.length); + for (let i = 0; i < p12Der.length; i++) { + p12Buffer[i] = p12Der.charCodeAt(i); + } + + return { p12Buffer: p12Buffer.buffer, password: p12Password, certificate }; } -export function parseCombinedPem(pemContent: string, password?: string): CertificateData { - const certMatch = pemContent.match(/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/); - const keyMatch = pemContent.match(/-----BEGIN (RSA |EC |ENCRYPTED )?PRIVATE KEY-----[\s\S]*?-----END (RSA |EC |ENCRYPTED )?PRIVATE KEY-----/); +export function parseCombinedPem( + pemContent: string, + password?: string +): CertificateData { + const certMatch = pemContent.match( + /-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/ + ); + const keyMatch = pemContent.match( + /-----BEGIN (RSA |EC |ENCRYPTED )?PRIVATE KEY-----[\s\S]*?-----END (RSA |EC |ENCRYPTED )?PRIVATE KEY-----/ + ); - if (!certMatch) { - throw new Error('No certificate found in PEM file'); - } + if (!certMatch) { + throw new Error('No certificate found in PEM file'); + } - if (!keyMatch) { - throw new Error('No private key found in PEM file'); - } + if (!keyMatch) { + throw new Error('No private key found in PEM file'); + } - return parsePemFiles(certMatch[0], keyMatch[0], password); + return parsePemFiles(certMatch[0], keyMatch[0], password); } /** @@ -85,30 +97,30 @@ export function parseCombinedPem(pemContent: string, password?: string): Certifi * The zgapdfsigner library tries to fetch issuer certificates from external URLs, * but those servers often don't have CORS headers. This proxy adds the necessary * CORS headers to allow the requests from the browser. - * + * * If you are self-hosting, you MUST deploy your own proxy using cloudflare/cors-proxy-worker.js or any other way of your choice * and set VITE_CORS_PROXY_URL environment variable. - * + * * If not set, certificates requiring external chain fetching will fail. */ const CORS_PROXY_URL = import.meta.env.VITE_CORS_PROXY_URL || ''; /** * Shared secret for signing proxy requests (HMAC-SHA256). - * + * * SECURITY NOTE FOR PRODUCTION: * Client-side secrets are NEVER truly hidden and they can be extracted from - * bundled JavaScript. - * + * bundled JavaScript. + * * For production deployments with sensitive requirements, you should: * 1. Use your own backend server to proxy certificate requests * 2. Keep the HMAC secret on your server ONLY (never in frontend code) * 3. Have your frontend call your server, which then calls the CORS proxy - * + * * 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. - * + * * To enable (optional): * 1. Generate a secret: openssl rand -hex 32 * 2. Set PROXY_SECRET on your Cloudflare Worker: npx wrangler secret put PROXY_SECRET @@ -116,26 +128,29 @@ const CORS_PROXY_URL = import.meta.env.VITE_CORS_PROXY_URL || ''; */ const CORS_PROXY_SECRET = import.meta.env.VITE_CORS_PROXY_SECRET || ''; -async function generateProxySignature(url: string, timestamp: number): Promise { - const encoder = new TextEncoder(); - const key = await crypto.subtle.importKey( - 'raw', - encoder.encode(CORS_PROXY_SECRET), - { name: 'HMAC', hash: 'SHA-256' }, - false, - ['sign'] - ); +async function generateProxySignature( + url: string, + timestamp: number +): Promise { + const encoder = new TextEncoder(); + const key = await crypto.subtle.importKey( + 'raw', + encoder.encode(CORS_PROXY_SECRET), + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign'] + ); - const message = `${url}${timestamp}`; - const signature = await crypto.subtle.sign( - 'HMAC', - key, - encoder.encode(message) - ); + const message = `${url}${timestamp}`; + const signature = await crypto.subtle.sign( + 'HMAC', + key, + encoder.encode(message) + ); - return Array.from(new Uint8Array(signature)) - .map(b => b.toString(16).padStart(2, '0')) - .join(''); + return Array.from(new Uint8Array(signature)) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); } /** @@ -143,141 +158,165 @@ async function generateProxySignature(url: string, timestamp: number): Promise void; + wrappedFetch: typeof fetch; + restore: () => void; } { - const originalFetch = window.fetch.bind(window); + if (fetchWrapRefCount === 0) { + savedOriginalFetch = window.fetch.bind(window); - const wrappedFetch: typeof fetch = async (input, init) => { - const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; + const originalFetch = savedOriginalFetch; - const isExternalCertificateUrl = ( - url.includes('.crt') || - url.includes('.cer') || - url.includes('.pem') || - url.includes('/certs/') || - url.includes('/ocsp') || - url.includes('/crl') || - url.includes('caIssuers') - ) && !url.startsWith(window.location.origin); + window.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = + typeof input === 'string' + ? input + : input instanceof URL + ? input.toString() + : input.url; - if (isExternalCertificateUrl && CORS_PROXY_URL) { - let proxyUrl = `${CORS_PROXY_URL}?url=${encodeURIComponent(url)}`; + const isExternalCertificateUrl = + (url.includes('.crt') || + url.includes('.cer') || + url.includes('.pem') || + url.includes('/certs/') || + url.includes('/ocsp') || + url.includes('/crl') || + url.includes('caIssuers')) && + !url.startsWith(window.location.origin); - if (CORS_PROXY_SECRET) { - const timestamp = Date.now(); - const signature = await generateProxySignature(url, timestamp); - proxyUrl += `&t=${timestamp}&sig=${signature}`; - console.log(`[CORS Proxy] Routing signed certificate request through proxy: ${url}`); - } else { - console.log(`[CORS Proxy] Routing certificate request through proxy: ${url}`); - } + if (isExternalCertificateUrl && CORS_PROXY_URL) { + let proxyUrl = `${CORS_PROXY_URL}?url=${encodeURIComponent(url)}`; - return originalFetch(proxyUrl, init); + if (CORS_PROXY_SECRET) { + const timestamp = Date.now(); + const signature = await generateProxySignature(url, timestamp); + proxyUrl += `&t=${timestamp}&sig=${signature}`; + console.log( + `[CORS Proxy] Routing signed certificate request through proxy: ${url}` + ); + } else { + console.log( + `[CORS Proxy] Routing certificate request through proxy: ${url}` + ); } - return originalFetch(input, init); - }; + return originalFetch(proxyUrl, init); + } - window.fetch = wrappedFetch; + return originalFetch(input, init); + }) as typeof fetch; + } - return { - wrappedFetch, - restore: () => { - window.fetch = originalFetch; - } - }; + fetchWrapRefCount++; + + return { + wrappedFetch: window.fetch, + restore: () => { + fetchWrapRefCount--; + if (fetchWrapRefCount === 0 && savedOriginalFetch) { + window.fetch = savedOriginalFetch; + savedOriginalFetch = null; + } + }, + }; } - export async function signPdf( - pdfBytes: Uint8Array, - certificateData: CertificateData, - options: SignPdfOptions = {} + pdfBytes: Uint8Array, + certificateData: CertificateData, + options: SignPdfOptions = {} ): Promise { - const signatureInfo = options.signatureInfo ?? {}; + const signatureInfo = options.signatureInfo ?? {}; - const signOptions: SignOption = { - p12cert: certificateData.p12Buffer, - pwd: certificateData.password, + const signOptions: SignOption = { + p12cert: certificateData.p12Buffer, + pwd: certificateData.password, + }; + + if (signatureInfo.reason) { + signOptions.reason = signatureInfo.reason; + } + + if (signatureInfo.location) { + signOptions.location = signatureInfo.location; + } + + if (signatureInfo.contactInfo) { + signOptions.contact = signatureInfo.contactInfo; + } + + if (options.visibleSignature?.enabled) { + const vs = options.visibleSignature; + + const drawinf = { + area: { + x: vs.x, + y: vs.y, + w: vs.width, + h: vs.height, + }, + pageidx: vs.page, + imgInfo: undefined as + | { imgData: ArrayBuffer; imgType: string } + | undefined, + textInfo: undefined as + | { text: string; size: number; color: string } + | undefined, }; - if (signatureInfo.reason) { - signOptions.reason = signatureInfo.reason; + if (vs.imageData && vs.imageType) { + drawinf.imgInfo = { + imgData: vs.imageData, + imgType: vs.imageType, + }; } - if (signatureInfo.location) { - signOptions.location = signatureInfo.location; + if (vs.text) { + drawinf.textInfo = { + text: vs.text, + size: vs.textSize ?? 12, + color: vs.textColor ?? '#000000', + }; } - if (signatureInfo.contactInfo) { - signOptions.contact = signatureInfo.contactInfo; - } + signOptions.drawinf = drawinf as SignOption['drawinf']; + } - if (options.visibleSignature?.enabled) { - const vs = options.visibleSignature; + const signer = new PdfSigner(signOptions); - const drawinf = { - area: { - x: vs.x, - y: vs.y, - w: vs.width, - h: vs.height, - }, - pageidx: vs.page, - imgInfo: undefined as { imgData: ArrayBuffer; imgType: string } | undefined, - textInfo: undefined as { text: string; size: number; color: string } | undefined, - }; + const { restore } = createCorsAwareFetch(); - if (vs.imageData && vs.imageType) { - drawinf.imgInfo = { - imgData: vs.imageData, - imgType: vs.imageType, - }; - } - - if (vs.text) { - drawinf.textInfo = { - text: vs.text, - size: vs.textSize ?? 12, - color: vs.textColor ?? '#000000', - }; - } - - signOptions.drawinf = drawinf as SignOption['drawinf']; - } - - const signer = new PdfSigner(signOptions); - - const { restore } = createCorsAwareFetch(); - - try { - const signedPdfBytes = await signer.sign(pdfBytes); - return new Uint8Array(signedPdfBytes); - } finally { - restore(); - } + try { + const signedPdfBytes = await signer.sign(pdfBytes); + return new Uint8Array(signedPdfBytes); + } finally { + restore(); + } } export function getCertificateInfo(certificate: forge.pki.Certificate): { - subject: string; - issuer: string; - validFrom: Date; - validTo: Date; - serialNumber: string; + subject: string; + issuer: string; + validFrom: Date; + validTo: Date; + serialNumber: string; } { - const subjectCN = certificate.subject.getField('CN'); - const issuerCN = certificate.issuer.getField('CN'); + const subjectCN = certificate.subject.getField('CN'); + const issuerCN = certificate.issuer.getField('CN'); - return { - subject: subjectCN?.value as string ?? 'Unknown', - issuer: issuerCN?.value as string ?? 'Unknown', - validFrom: certificate.validity.notBefore, - validTo: certificate.validity.notAfter, - serialNumber: certificate.serialNumber, - }; + return { + subject: (subjectCN?.value as string) ?? 'Unknown', + issuer: (issuerCN?.value as string) ?? 'Unknown', + validFrom: certificate.validity.notBefore, + validTo: certificate.validity.notAfter, + serialNumber: certificate.serialNumber, + }; } diff --git a/src/js/logic/edit-attachments-page.ts b/src/js/logic/edit-attachments-page.ts index cbfdf8c..d7fe32a 100644 --- a/src/js/logic/edit-attachments-page.ts +++ b/src/js/logic/edit-attachments-page.ts @@ -2,352 +2,396 @@ import { EditAttachmentState, AttachmentInfo } from '@/types'; import { showLoader, hideLoader, showAlert } from '../ui.js'; import { downloadFile, formatBytes } from '../utils/helpers.js'; import { createIcons, icons } from 'lucide'; +import { isCpdfAvailable } from '../utils/cpdf-helper.js'; +import { + showWasmRequiredDialog, + WasmProvider, +} from '../utils/wasm-provider.js'; -const worker = new Worker(import.meta.env.BASE_URL + 'workers/edit-attachments.worker.js'); +const worker = new Worker( + import.meta.env.BASE_URL + 'workers/edit-attachments.worker.js' +); const pageState: EditAttachmentState = { - file: null, - allAttachments: [], - attachmentsToRemove: new Set(), + file: null, + allAttachments: [], + attachmentsToRemove: new Set(), }; function resetState() { - pageState.file = null; - pageState.allAttachments = []; - pageState.attachmentsToRemove.clear(); + pageState.file = null; + pageState.allAttachments = []; + pageState.attachmentsToRemove.clear(); - const fileDisplayArea = document.getElementById('file-display-area'); - if (fileDisplayArea) fileDisplayArea.innerHTML = ''; + const fileDisplayArea = document.getElementById('file-display-area'); + if (fileDisplayArea) fileDisplayArea.innerHTML = ''; - const toolOptions = document.getElementById('tool-options'); - if (toolOptions) toolOptions.classList.add('hidden'); + const toolOptions = document.getElementById('tool-options'); + if (toolOptions) toolOptions.classList.add('hidden'); - const attachmentsList = document.getElementById('attachments-list'); - if (attachmentsList) attachmentsList.innerHTML = ''; + const attachmentsList = document.getElementById('attachments-list'); + if (attachmentsList) attachmentsList.innerHTML = ''; - const processBtn = document.getElementById('process-btn'); - if (processBtn) processBtn.classList.add('hidden'); + const processBtn = document.getElementById('process-btn'); + if (processBtn) processBtn.classList.add('hidden'); - const fileInput = document.getElementById('file-input') as HTMLInputElement; - if (fileInput) fileInput.value = ''; + const fileInput = document.getElementById('file-input') as HTMLInputElement; + if (fileInput) fileInput.value = ''; } worker.onmessage = function (e) { - const data = e.data; + const data = e.data; - if (data.status === 'success' && data.attachments !== undefined) { - pageState.allAttachments = data.attachments.map(function (att: any) { - return { - ...att, - data: new Uint8Array(att.data) - }; - }); + if (data.status === 'success' && data.attachments !== undefined) { + pageState.allAttachments = data.attachments.map(function (att: any) { + return { + ...att, + data: new Uint8Array(att.data), + }; + }); - displayAttachments(data.attachments); - hideLoader(); - } else if (data.status === 'success' && data.modifiedPDF !== undefined) { - hideLoader(); + displayAttachments(data.attachments); + hideLoader(); + } else if (data.status === 'success' && data.modifiedPDF !== undefined) { + hideLoader(); - const originalName = pageState.file?.name.replace(/\.pdf$/i, '') || 'document'; - downloadFile( - new Blob([new Uint8Array(data.modifiedPDF)], { type: 'application/pdf' }), - `${originalName}_edited.pdf` - ); + const originalName = + pageState.file?.name.replace(/\.pdf$/i, '') || 'document'; + downloadFile( + new Blob([new Uint8Array(data.modifiedPDF)], { type: 'application/pdf' }), + `${originalName}_edited.pdf` + ); - showAlert('Success', 'Attachments updated successfully!', 'success', function () { - resetState(); - }); - } else if (data.status === 'error') { - hideLoader(); - showAlert('Error', data.message || 'Unknown error occurred.'); - } + showAlert( + 'Success', + 'Attachments updated successfully!', + 'success', + function () { + resetState(); + } + ); + } else if (data.status === 'error') { + hideLoader(); + showAlert('Error', data.message || 'Unknown error occurred.'); + } }; worker.onerror = function (error) { - hideLoader(); - console.error('Worker error:', error); - showAlert('Error', 'Worker error occurred. Check console for details.'); + hideLoader(); + console.error('Worker error:', error); + showAlert('Error', 'Worker error occurred. Check console for details.'); }; function displayAttachments(attachments: AttachmentInfo[]) { - const attachmentsList = document.getElementById('attachments-list'); - const processBtn = document.getElementById('process-btn'); + const attachmentsList = document.getElementById('attachments-list'); + const processBtn = document.getElementById('process-btn'); - if (!attachmentsList) return; + if (!attachmentsList) return; - attachmentsList.innerHTML = ''; + attachmentsList.innerHTML = ''; - if (attachments.length === 0) { - const noAttachments = document.createElement('p'); - noAttachments.className = 'text-gray-400 text-center py-4'; - noAttachments.textContent = 'No attachments found in this PDF.'; - attachmentsList.appendChild(noAttachments); - return; + if (attachments.length === 0) { + const noAttachments = document.createElement('p'); + noAttachments.className = 'text-gray-400 text-center py-4'; + noAttachments.textContent = 'No attachments found in this PDF.'; + attachmentsList.appendChild(noAttachments); + return; + } + + // Controls container + const controlsContainer = document.createElement('div'); + controlsContainer.className = 'attachments-controls mb-4 flex justify-end'; + + const removeAllBtn = document.createElement('button'); + removeAllBtn.className = + 'bg-red-500 hover:bg-red-600 text-white px-4 py-2 rounded text-sm'; + removeAllBtn.textContent = 'Remove All Attachments'; + removeAllBtn.onclick = function () { + if (pageState.allAttachments.length === 0) return; + + const allSelected = pageState.allAttachments.every(function (attachment) { + return pageState.attachmentsToRemove.has(attachment.index); + }); + + if (allSelected) { + pageState.allAttachments.forEach(function (attachment) { + pageState.attachmentsToRemove.delete(attachment.index); + const element = document.querySelector( + `[data-attachment-index="${attachment.index}"]` + ); + if (element) { + element.classList.remove('opacity-50', 'line-through'); + const btn = element.querySelector('button'); + if (btn) { + btn.classList.remove('bg-gray-600'); + btn.classList.add('bg-red-600'); + } + } + }); + removeAllBtn.textContent = 'Remove All Attachments'; + } else { + pageState.allAttachments.forEach(function (attachment) { + pageState.attachmentsToRemove.add(attachment.index); + const element = document.querySelector( + `[data-attachment-index="${attachment.index}"]` + ); + if (element) { + element.classList.add('opacity-50', 'line-through'); + const btn = element.querySelector('button'); + if (btn) { + btn.classList.add('bg-gray-600'); + btn.classList.remove('bg-red-600'); + } + } + }); + removeAllBtn.textContent = 'Deselect All'; + } + }; + + controlsContainer.appendChild(removeAllBtn); + attachmentsList.appendChild(controlsContainer); + + // Attachment items + for (const attachment of attachments) { + const attachmentDiv = document.createElement('div'); + attachmentDiv.className = + 'flex items-center justify-between p-3 bg-gray-800 rounded-lg border border-gray-700'; + attachmentDiv.dataset.attachmentIndex = attachment.index.toString(); + + const infoDiv = document.createElement('div'); + infoDiv.className = 'flex-1'; + + const nameSpan = document.createElement('span'); + nameSpan.className = 'text-white font-medium block'; + nameSpan.textContent = attachment.name; + + const levelSpan = document.createElement('span'); + levelSpan.className = 'text-gray-400 text-sm block'; + if (attachment.page === 0) { + levelSpan.textContent = 'Document-level attachment'; + } else { + levelSpan.textContent = `Page ${attachment.page} attachment`; } - // Controls container - const controlsContainer = document.createElement('div'); - controlsContainer.className = 'attachments-controls mb-4 flex justify-end'; + infoDiv.append(nameSpan, levelSpan); - const removeAllBtn = document.createElement('button'); - removeAllBtn.className = 'bg-red-500 hover:bg-red-600 text-white px-4 py-2 rounded text-sm'; - removeAllBtn.textContent = 'Remove All Attachments'; - removeAllBtn.onclick = function () { - if (pageState.allAttachments.length === 0) return; + const actionsDiv = document.createElement('div'); + actionsDiv.className = 'flex items-center gap-2'; - const allSelected = pageState.allAttachments.every(function (attachment) { - return pageState.attachmentsToRemove.has(attachment.index); - }); + const removeBtn = document.createElement('button'); + removeBtn.className = `${pageState.attachmentsToRemove.has(attachment.index) ? 'bg-gray-600' : 'bg-red-600'} hover:bg-red-700 text-white px-3 py-1 rounded text-sm`; + removeBtn.innerHTML = ''; + removeBtn.title = 'Remove attachment'; + removeBtn.onclick = function () { + if (pageState.attachmentsToRemove.has(attachment.index)) { + pageState.attachmentsToRemove.delete(attachment.index); + attachmentDiv.classList.remove('opacity-50', 'line-through'); + removeBtn.classList.remove('bg-gray-600'); + removeBtn.classList.add('bg-red-600'); + } else { + pageState.attachmentsToRemove.add(attachment.index); + attachmentDiv.classList.add('opacity-50', 'line-through'); + removeBtn.classList.add('bg-gray-600'); + removeBtn.classList.remove('bg-red-600'); + } - if (allSelected) { - pageState.allAttachments.forEach(function (attachment) { - pageState.attachmentsToRemove.delete(attachment.index); - const element = document.querySelector(`[data-attachment-index="${attachment.index}"]`); - if (element) { - element.classList.remove('opacity-50', 'line-through'); - const btn = element.querySelector('button'); - if (btn) { - btn.classList.remove('bg-gray-600'); - btn.classList.add('bg-red-600'); - } - } - }); - removeAllBtn.textContent = 'Remove All Attachments'; - } else { - pageState.allAttachments.forEach(function (attachment) { - pageState.attachmentsToRemove.add(attachment.index); - const element = document.querySelector(`[data-attachment-index="${attachment.index}"]`); - if (element) { - element.classList.add('opacity-50', 'line-through'); - const btn = element.querySelector('button'); - if (btn) { - btn.classList.add('bg-gray-600'); - btn.classList.remove('bg-red-600'); - } - } - }); - removeAllBtn.textContent = 'Deselect All'; - } + const allSelected = pageState.allAttachments.every(function (att) { + return pageState.attachmentsToRemove.has(att.index); + }); + removeAllBtn.textContent = allSelected + ? 'Deselect All' + : 'Remove All Attachments'; }; - controlsContainer.appendChild(removeAllBtn); - attachmentsList.appendChild(controlsContainer); + actionsDiv.append(removeBtn); + attachmentDiv.append(infoDiv, actionsDiv); + attachmentsList.appendChild(attachmentDiv); + } - // Attachment items - for (const attachment of attachments) { - const attachmentDiv = document.createElement('div'); - attachmentDiv.className = 'flex items-center justify-between p-3 bg-gray-800 rounded-lg border border-gray-700'; - attachmentDiv.dataset.attachmentIndex = attachment.index.toString(); + createIcons({ icons }); - const infoDiv = document.createElement('div'); - infoDiv.className = 'flex-1'; - - const nameSpan = document.createElement('span'); - nameSpan.className = 'text-white font-medium block'; - nameSpan.textContent = attachment.name; - - const levelSpan = document.createElement('span'); - levelSpan.className = 'text-gray-400 text-sm block'; - if (attachment.page === 0) { - levelSpan.textContent = 'Document-level attachment'; - } else { - levelSpan.textContent = `Page ${attachment.page} attachment`; - } - - infoDiv.append(nameSpan, levelSpan); - - const actionsDiv = document.createElement('div'); - actionsDiv.className = 'flex items-center gap-2'; - - const removeBtn = document.createElement('button'); - removeBtn.className = `${pageState.attachmentsToRemove.has(attachment.index) ? 'bg-gray-600' : 'bg-red-600'} hover:bg-red-700 text-white px-3 py-1 rounded text-sm`; - removeBtn.innerHTML = ''; - removeBtn.title = 'Remove attachment'; - removeBtn.onclick = function () { - if (pageState.attachmentsToRemove.has(attachment.index)) { - pageState.attachmentsToRemove.delete(attachment.index); - attachmentDiv.classList.remove('opacity-50', 'line-through'); - removeBtn.classList.remove('bg-gray-600'); - removeBtn.classList.add('bg-red-600'); - } else { - pageState.attachmentsToRemove.add(attachment.index); - attachmentDiv.classList.add('opacity-50', 'line-through'); - removeBtn.classList.add('bg-gray-600'); - removeBtn.classList.remove('bg-red-600'); - } - - const allSelected = pageState.allAttachments.every(function (att) { - return pageState.attachmentsToRemove.has(att.index); - }); - removeAllBtn.textContent = allSelected ? 'Deselect All' : 'Remove All Attachments'; - }; - - actionsDiv.append(removeBtn); - attachmentDiv.append(infoDiv, actionsDiv); - attachmentsList.appendChild(attachmentDiv); - } - - createIcons({ icons }); - - if (processBtn) processBtn.classList.remove('hidden'); + if (processBtn) processBtn.classList.remove('hidden'); } async function loadAttachments() { - if (!pageState.file) return; + if (!pageState.file) return; - showLoader('Loading attachments...'); + showLoader('Loading attachments...'); - try { - const fileBuffer = await pageState.file.arrayBuffer(); + // Check if CPDF is configured + if (!isCpdfAvailable()) { + showWasmRequiredDialog('cpdf'); + hideLoader(); + return; + } - const message = { - command: 'get-attachments', - fileBuffer: fileBuffer, - fileName: pageState.file.name - }; + try { + const fileBuffer = await pageState.file.arrayBuffer(); - worker.postMessage(message, [fileBuffer]); - } catch (error) { - console.error('Error loading attachments:', error); - hideLoader(); - showAlert('Error', 'Failed to load attachments from PDF.'); - } + const message = { + command: 'get-attachments', + fileBuffer: fileBuffer, + fileName: pageState.file.name, + cpdfUrl: WasmProvider.getUrl('cpdf')! + 'coherentpdf.browser.min.js', + }; + + worker.postMessage(message, [fileBuffer]); + } catch (error) { + console.error('Error loading attachments:', error); + hideLoader(); + showAlert('Error', 'Failed to load attachments from PDF.'); + } } async function saveChanges() { - if (!pageState.file) { - showAlert('Error', 'No PDF file loaded.'); - return; - } + if (!pageState.file) { + showAlert('Error', 'No PDF file loaded.'); + return; + } - if (pageState.attachmentsToRemove.size === 0) { - showAlert('No Changes', 'No attachments selected for removal.'); - return; - } + if (pageState.attachmentsToRemove.size === 0) { + showAlert('No Changes', 'No attachments selected for removal.'); + return; + } - showLoader('Processing attachments...'); + showLoader('Processing attachments...'); - try { - const fileBuffer = await pageState.file.arrayBuffer(); + // Check if CPDF is configured (double check) + if (!isCpdfAvailable()) { + showWasmRequiredDialog('cpdf'); + hideLoader(); + return; + } - const message = { - command: 'edit-attachments', - fileBuffer: fileBuffer, - fileName: pageState.file.name, - attachmentsToRemove: Array.from(pageState.attachmentsToRemove) - }; + try { + const fileBuffer = await pageState.file.arrayBuffer(); - worker.postMessage(message, [fileBuffer]); - } catch (error) { - console.error('Error editing attachments:', error); - hideLoader(); - showAlert('Error', 'Failed to edit attachments.'); - } + const message = { + command: 'edit-attachments', + fileBuffer: fileBuffer, + fileName: pageState.file.name, + attachmentsToRemove: Array.from(pageState.attachmentsToRemove), + cpdfUrl: WasmProvider.getUrl('cpdf')! + 'coherentpdf.browser.min.js', + }; + + worker.postMessage(message, [fileBuffer]); + } catch (error) { + console.error('Error editing attachments:', error); + hideLoader(); + showAlert('Error', 'Failed to edit attachments.'); + } } async function updateUI() { - const fileDisplayArea = document.getElementById('file-display-area'); - const toolOptions = document.getElementById('tool-options'); + const fileDisplayArea = document.getElementById('file-display-area'); + const toolOptions = document.getElementById('tool-options'); - if (!fileDisplayArea) return; + if (!fileDisplayArea) return; - fileDisplayArea.innerHTML = ''; + fileDisplayArea.innerHTML = ''; - if (pageState.file) { - const fileDiv = document.createElement('div'); - fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm'; + if (pageState.file) { + const fileDiv = document.createElement('div'); + fileDiv.className = + 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm'; - const infoContainer = document.createElement('div'); - infoContainer.className = 'flex flex-col overflow-hidden'; + const infoContainer = document.createElement('div'); + infoContainer.className = 'flex flex-col overflow-hidden'; - const nameSpan = document.createElement('div'); - nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1'; - nameSpan.textContent = pageState.file.name; + const nameSpan = document.createElement('div'); + nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1'; + nameSpan.textContent = pageState.file.name; - const metaSpan = document.createElement('div'); - metaSpan.className = 'text-xs text-gray-400'; - metaSpan.textContent = formatBytes(pageState.file.size); + const metaSpan = document.createElement('div'); + metaSpan.className = 'text-xs text-gray-400'; + metaSpan.textContent = formatBytes(pageState.file.size); - infoContainer.append(nameSpan, metaSpan); + infoContainer.append(nameSpan, metaSpan); - const removeBtn = document.createElement('button'); - removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0'; - removeBtn.innerHTML = ''; - removeBtn.onclick = function () { - resetState(); - }; + const removeBtn = document.createElement('button'); + removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0'; + removeBtn.innerHTML = ''; + removeBtn.onclick = function () { + resetState(); + }; - fileDiv.append(infoContainer, removeBtn); - fileDisplayArea.appendChild(fileDiv); - createIcons({ icons }); + fileDiv.append(infoContainer, removeBtn); + fileDisplayArea.appendChild(fileDiv); + createIcons({ icons }); - if (toolOptions) toolOptions.classList.remove('hidden'); + if (toolOptions) toolOptions.classList.remove('hidden'); - await loadAttachments(); - } else { - if (toolOptions) toolOptions.classList.add('hidden'); - } + await loadAttachments(); + } else { + if (toolOptions) toolOptions.classList.add('hidden'); + } } function handleFileSelect(files: FileList | null) { - if (files && files.length > 0) { - const file = files[0]; - if (file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf')) { - pageState.file = file; - updateUI(); - } + if (files && files.length > 0) { + const file = files[0]; + if ( + file.type === 'application/pdf' || + file.name.toLowerCase().endsWith('.pdf') + ) { + pageState.file = file; + updateUI(); } + } } document.addEventListener('DOMContentLoaded', function () { - const fileInput = document.getElementById('file-input') as HTMLInputElement; - const dropZone = document.getElementById('drop-zone'); - const processBtn = document.getElementById('process-btn'); - const backBtn = document.getElementById('back-to-tools'); + const fileInput = document.getElementById('file-input') as HTMLInputElement; + const dropZone = document.getElementById('drop-zone'); + const processBtn = document.getElementById('process-btn'); + const backBtn = document.getElementById('back-to-tools'); - if (backBtn) { - backBtn.addEventListener('click', function () { - window.location.href = import.meta.env.BASE_URL; + if (backBtn) { + backBtn.addEventListener('click', function () { + window.location.href = import.meta.env.BASE_URL; + }); + } + + if (fileInput && dropZone) { + fileInput.addEventListener('change', function (e) { + handleFileSelect((e.target as HTMLInputElement).files); + }); + + dropZone.addEventListener('dragover', function (e) { + e.preventDefault(); + dropZone.classList.add('bg-gray-700'); + }); + + dropZone.addEventListener('dragleave', function (e) { + e.preventDefault(); + dropZone.classList.remove('bg-gray-700'); + }); + + dropZone.addEventListener('drop', function (e) { + e.preventDefault(); + dropZone.classList.remove('bg-gray-700'); + const files = e.dataTransfer?.files; + if (files && files.length > 0) { + const pdfFiles = Array.from(files).filter(function (f) { + return ( + f.type === 'application/pdf' || + f.name.toLowerCase().endsWith('.pdf') + ); }); - } + if (pdfFiles.length > 0) { + const dataTransfer = new DataTransfer(); + dataTransfer.items.add(pdfFiles[0]); + handleFileSelect(dataTransfer.files); + } + } + }); - if (fileInput && dropZone) { - fileInput.addEventListener('change', function (e) { - handleFileSelect((e.target as HTMLInputElement).files); - }); + fileInput.addEventListener('click', function () { + fileInput.value = ''; + }); + } - dropZone.addEventListener('dragover', function (e) { - e.preventDefault(); - dropZone.classList.add('bg-gray-700'); - }); - - dropZone.addEventListener('dragleave', function (e) { - e.preventDefault(); - dropZone.classList.remove('bg-gray-700'); - }); - - dropZone.addEventListener('drop', function (e) { - e.preventDefault(); - dropZone.classList.remove('bg-gray-700'); - const files = e.dataTransfer?.files; - if (files && files.length > 0) { - const pdfFiles = Array.from(files).filter(function (f) { - return f.type === 'application/pdf' || f.name.toLowerCase().endsWith('.pdf'); - }); - if (pdfFiles.length > 0) { - const dataTransfer = new DataTransfer(); - dataTransfer.items.add(pdfFiles[0]); - handleFileSelect(dataTransfer.files); - } - } - }); - - fileInput.addEventListener('click', function () { - fileInput.value = ''; - }); - } - - if (processBtn) { - processBtn.addEventListener('click', saveChanges); - } + if (processBtn) { + processBtn.addEventListener('click', saveChanges); + } }); diff --git a/src/js/logic/edit-pdf-page.ts b/src/js/logic/edit-pdf-page.ts index f0ab56f..d6a7e98 100644 --- a/src/js/logic/edit-pdf-page.ts +++ b/src/js/logic/edit-pdf-page.ts @@ -1,160 +1,271 @@ // Logic for PDF Editor Page import { createIcons, icons } from 'lucide'; import { showAlert, showLoader, hideLoader } from '../ui.js'; -import { formatBytes } from '../utils/helpers.js'; +import { formatBytes, downloadFile } from '../utils/helpers.js'; const embedPdfWasmUrl = new URL( - 'embedpdf-snippet/dist/pdfium.wasm', - import.meta.url + 'embedpdf-snippet/dist/pdfium.wasm', + import.meta.url ).href; -let currentPdfUrl: string | null = null; +let viewerInstance: any = null; +let docManagerPlugin: any = null; +let isViewerInitialized = false; +const fileEntryMap = new Map(); + +function resetViewer() { + const pdfWrapper = document.getElementById('embed-pdf-wrapper'); + const pdfContainer = document.getElementById('embed-pdf-container'); + const downloadBtn = document.getElementById('download-edited-pdf'); + const fileDisplayArea = document.getElementById('file-display-area'); + const fileInput = document.getElementById('file-input') as HTMLInputElement; + if (pdfContainer) pdfContainer.textContent = ''; + if (pdfWrapper) pdfWrapper.classList.add('hidden'); + if (downloadBtn) downloadBtn.classList.add('hidden'); + if (fileDisplayArea) fileDisplayArea.innerHTML = ''; + if (fileInput) fileInput.value = ''; + viewerInstance = null; + docManagerPlugin = null; + isViewerInitialized = false; + fileEntryMap.clear(); +} + +function removeFileEntry(documentId: string) { + const entry = fileEntryMap.get(documentId); + if (entry) { + entry.remove(); + fileEntryMap.delete(documentId); + } + if (fileEntryMap.size === 0) { + resetViewer(); + } +} if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', initializePage); + document.addEventListener('DOMContentLoaded', initializePage); } else { - initializePage(); + initializePage(); } function initializePage() { - createIcons({ icons }); + createIcons({ icons }); - const fileInput = document.getElementById('file-input') as HTMLInputElement; - const dropZone = document.getElementById('drop-zone'); + const fileInput = document.getElementById('file-input') as HTMLInputElement; + const dropZone = document.getElementById('drop-zone'); - if (fileInput) { - fileInput.addEventListener('change', handleFileUpload); - } + if (fileInput) { + fileInput.addEventListener('change', handleFileUpload); + } - 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); - } - }); - - fileInput?.addEventListener('click', () => { - if (fileInput) fileInput.value = ''; - }); - } - - document.getElementById('back-to-tools')?.addEventListener('click', () => { - window.location.href = import.meta.env.BASE_URL; + 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); + } + }); + + fileInput?.addEventListener('click', () => { + if (fileInput) fileInput.value = ''; + }); + } + + document.getElementById('back-to-tools')?.addEventListener('click', () => { + window.location.href = import.meta.env.BASE_URL; + }); } async function handleFileUpload(e: Event) { - const input = e.target as HTMLInputElement; - if (input.files && input.files.length > 0) { - await handleFiles(input.files); - } + const input = e.target as HTMLInputElement; + if (input.files && input.files.length > 0) { + await handleFiles(input.files); + } } async function handleFiles(files: FileList) { - const file = files[0]; - if (!file || file.type !== 'application/pdf') { - showAlert('Invalid File', 'Please upload a valid PDF file.'); - return; - } + const pdfFiles = Array.from(files).filter( + (f) => f.type === 'application/pdf' + ); + if (pdfFiles.length === 0) { + showAlert('Invalid File', 'Please upload a valid PDF file.'); + return; + } - showLoader('Loading PDF Editor...'); + showLoader('Loading PDF Editor...'); - try { - const pdfWrapper = document.getElementById('embed-pdf-wrapper'); - const pdfContainer = document.getElementById('embed-pdf-container'); - const uploader = document.getElementById('tool-uploader'); - const dropZone = document.getElementById('drop-zone'); - const fileDisplayArea = document.getElementById('file-display-area'); + try { + const pdfWrapper = document.getElementById('embed-pdf-wrapper'); + const pdfContainer = document.getElementById('embed-pdf-container'); + const fileDisplayArea = document.getElementById('file-display-area'); - if (!pdfWrapper || !pdfContainer || !uploader || !dropZone || !fileDisplayArea) return; + if (!pdfWrapper || !pdfContainer || !fileDisplayArea) return; + if (!isViewerInitialized) { + const firstFile = pdfFiles[0]; + const firstBuffer = await firstFile.arrayBuffer(); - fileDisplayArea.innerHTML = ''; - const fileDiv = document.createElement('div'); - fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg'; + pdfContainer.textContent = ''; + pdfWrapper.classList.remove('hidden'); - const infoContainer = document.createElement('div'); - infoContainer.className = 'flex flex-col flex-1 min-w-0'; + const { default: EmbedPDF } = await import('embedpdf-snippet'); + viewerInstance = EmbedPDF.init({ + type: 'container', + target: pdfContainer, + worker: true, + wasmUrl: embedPdfWasmUrl, + export: { + defaultFileName: firstFile.name, + }, + documentManager: { + maxDocuments: 10, + }, + tabBar: 'always', + }); - const nameSpan = document.createElement('div'); - nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1'; - nameSpan.textContent = file.name; + const registry = await viewerInstance.registry; + docManagerPlugin = registry.getPlugin('document-manager').provides(); - const metaSpan = document.createElement('div'); - metaSpan.className = 'text-xs text-gray-400'; - metaSpan.textContent = formatBytes(file.size); + docManagerPlugin.onDocumentClosed((data: any) => { + const docId = data?.id || data; + removeFileEntry(docId); + }); - 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 = () => { - if (currentPdfUrl) { - URL.revokeObjectURL(currentPdfUrl); - currentPdfUrl = null; - } - pdfContainer.textContent = ''; - pdfWrapper.classList.add('hidden'); - fileDisplayArea.innerHTML = ''; - const fileInput = document.getElementById('file-input') as HTMLInputElement; - if (fileInput) fileInput.value = ''; - }; - - fileDiv.append(infoContainer, removeBtn); - fileDisplayArea.appendChild(fileDiv); - createIcons({ icons }); - - pdfContainer.textContent = ''; - if (currentPdfUrl) { - URL.revokeObjectURL(currentPdfUrl); + docManagerPlugin.onDocumentOpened((data: any) => { + const docId = data?.id; + const docName = data?.name; + if (!docId) return; + const pendingEntry = fileDisplayArea.querySelector( + `[data-pending-name="${CSS.escape(docName)}"]` + ) as HTMLElement; + if (pendingEntry) { + pendingEntry.removeAttribute('data-pending-name'); + fileEntryMap.set(docId, pendingEntry); + const removeBtn = pendingEntry.querySelector( + '[data-remove-btn]' + ) as HTMLElement; + if (removeBtn) { + removeBtn.onclick = () => { + docManagerPlugin.closeDocument(docId); + }; + } } - pdfWrapper.classList.remove('hidden'); + }); - const fileURL = URL.createObjectURL(file); - currentPdfUrl = fileURL; + addFileEntries(fileDisplayArea, pdfFiles); - const { default: EmbedPDF } = await import('embedpdf-snippet'); - EmbedPDF.init({ - type: 'container', - target: pdfContainer, - src: fileURL, - worker: true, - wasmUrl: embedPdfWasmUrl, + docManagerPlugin.openDocumentBuffer({ + buffer: firstBuffer, + name: firstFile.name, + autoActivate: true, + }); + + for (let i = 1; i < pdfFiles.length; i++) { + const buffer = await pdfFiles[i].arrayBuffer(); + docManagerPlugin.openDocumentBuffer({ + buffer, + name: pdfFiles[i].name, + autoActivate: false, }); + } - // Update back button to reset state - const backBtn = document.getElementById('back-to-tools'); - if (backBtn) { - // Clone to remove old listeners - const newBackBtn = backBtn.cloneNode(true); - backBtn.parentNode?.replaceChild(newBackBtn, backBtn); + isViewerInitialized = true; - newBackBtn.addEventListener('click', () => { - if (currentPdfUrl) { - URL.revokeObjectURL(currentPdfUrl); - currentPdfUrl = null; - } - window.location.href = import.meta.env.BASE_URL; - }); + let downloadBtn = document.getElementById('download-edited-pdf'); + if (!downloadBtn) { + downloadBtn = document.createElement('button'); + downloadBtn.id = 'download-edited-pdf'; + downloadBtn.className = 'btn-gradient w-full mt-6'; + downloadBtn.textContent = 'Download Edited PDF'; + pdfWrapper.appendChild(downloadBtn); + } + downloadBtn.classList.remove('hidden'); + + downloadBtn.onclick = async () => { + try { + const exportPlugin = registry.getPlugin('export').provides(); + const arrayBuffer = await exportPlugin.saveAsCopy().toPromise(); + const blob = new Blob([arrayBuffer], { type: 'application/pdf' }); + downloadFile(blob, 'edited-document.pdf'); + } catch (err) { + console.error('Error downloading PDF:', err); + showAlert('Error', 'Failed to download the edited PDF.'); } + }; - } catch (error) { - console.error('Error loading PDF Editor:', error); - showAlert('Error', 'Failed to load the PDF Editor.'); - } finally { - hideLoader(); + const backBtn = document.getElementById('back-to-tools'); + if (backBtn) { + const newBackBtn = backBtn.cloneNode(true); + backBtn.parentNode?.replaceChild(newBackBtn, backBtn); + + newBackBtn.addEventListener('click', () => { + window.location.href = import.meta.env.BASE_URL; + }); + } + } else { + addFileEntries(fileDisplayArea, pdfFiles); + + for (const file of pdfFiles) { + const buffer = await file.arrayBuffer(); + docManagerPlugin.openDocumentBuffer({ + buffer, + name: file.name, + autoActivate: true, + }); + } } + } catch (error) { + console.error('Error loading PDF Editor:', error); + showAlert('Error', 'Failed to load the PDF Editor.'); + } finally { + hideLoader(); + } +} + +function addFileEntries(fileDisplayArea: HTMLElement, files: File[]) { + for (const file of files) { + const fileDiv = document.createElement('div'); + fileDiv.className = + 'flex items-center justify-between bg-gray-700 p-3 rounded-lg'; + fileDiv.setAttribute('data-pending-name', file.name); + + 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 = file.name; + + const metaSpan = document.createElement('div'); + metaSpan.className = 'text-xs text-gray-400'; + metaSpan.textContent = formatBytes(file.size); + + 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.setAttribute('data-remove-btn', 'true'); + removeBtn.onclick = () => { + fileDiv.remove(); + if (fileDisplayArea.children.length === 0) { + resetViewer(); + } + }; + + fileDiv.append(infoContainer, removeBtn); + fileDisplayArea.appendChild(fileDiv); + } + + createIcons({ icons }); } diff --git a/src/js/logic/email-to-pdf-page.ts b/src/js/logic/email-to-pdf-page.ts index 09d856d..9c78ada 100644 --- a/src/js/logic/email-to-pdf-page.ts +++ b/src/js/logic/email-to-pdf-page.ts @@ -3,8 +3,9 @@ import { downloadFile, formatBytes } from '../utils/helpers.js'; import { state } from '../state.js'; import { createIcons, icons } from 'lucide'; import { parseEmailFile, renderEmailToHtml } from './email-to-pdf.js'; -import { PyMuPDF } from '@bentopdf/pymupdf-wasm'; -import { getWasmBaseUrl } from '../config/wasm-cdn-config.js'; +import { isWasmAvailable, getWasmBaseUrl } from '../config/wasm-cdn-config.js'; +import { showWasmRequiredDialog } from '../utils/wasm-provider.js'; +import { loadPyMuPDF, isPyMuPDFAvailable } from '../utils/pymupdf-loader.js'; const EXTENSIONS = ['.eml', '.msg']; const TOOL_NAME = 'Email'; @@ -109,8 +110,7 @@ document.addEventListener('DOMContentLoaded', () => { const includeAttachments = includeAttachmentsCheckbox?.checked ?? true; showLoader('Loading PDF engine...'); - const pymupdf = new PyMuPDF(getWasmBaseUrl('pymupdf')); - await pymupdf.load(); + const pymupdf = await loadPyMuPDF(); if (state.files.length === 1) { const originalFile = state.files[0]; diff --git a/src/js/logic/epub-to-pdf-page.ts b/src/js/logic/epub-to-pdf-page.ts index 0aa0710..c629dab 100644 --- a/src/js/logic/epub-to-pdf-page.ts +++ b/src/js/logic/epub-to-pdf-page.ts @@ -2,205 +2,216 @@ import { showLoader, hideLoader, showAlert } from '../ui.js'; import { downloadFile, formatBytes } from '../utils/helpers.js'; import { state } from '../state.js'; import { createIcons, icons } from 'lucide'; -import { PyMuPDF } from '@bentopdf/pymupdf-wasm'; -import { getWasmBaseUrl } from '../config/wasm-cdn-config.js'; +import { isWasmAvailable, getWasmBaseUrl } from '../config/wasm-cdn-config.js'; +import { showWasmRequiredDialog } from '../utils/wasm-provider.js'; +import { loadPyMuPDF, isPyMuPDFAvailable } from '../utils/pymupdf-loader.js'; const FILETYPE = 'epub'; const EXTENSIONS = ['.epub']; const TOOL_NAME = 'EPUB'; document.addEventListener('DOMContentLoaded', () => { - const fileInput = document.getElementById('file-input') as HTMLInputElement; - const dropZone = document.getElementById('drop-zone'); - const processBtn = document.getElementById('process-btn'); - const fileDisplayArea = document.getElementById('file-display-area'); - const fileControls = document.getElementById('file-controls'); - const addMoreBtn = document.getElementById('add-more-btn'); - const clearFilesBtn = document.getElementById('clear-files-btn'); - const backBtn = document.getElementById('back-to-tools'); + const fileInput = document.getElementById('file-input') as HTMLInputElement; + const dropZone = document.getElementById('drop-zone'); + const processBtn = document.getElementById('process-btn'); + const fileDisplayArea = document.getElementById('file-display-area'); + const fileControls = document.getElementById('file-controls'); + const addMoreBtn = document.getElementById('add-more-btn'); + const clearFilesBtn = document.getElementById('clear-files-btn'); + const backBtn = document.getElementById('back-to-tools'); - if (backBtn) { - backBtn.addEventListener('click', () => { - window.location.href = import.meta.env.BASE_URL; - }); + if (backBtn) { + backBtn.addEventListener('click', () => { + window.location.href = import.meta.env.BASE_URL; + }); + } + + const convertOptions = document.getElementById('convert-options'); + + // ... (existing listeners) + + const updateUI = async () => { + if (!fileDisplayArea || !processBtn || !fileControls) return; + + if (state.files.length > 0) { + fileDisplayArea.innerHTML = ''; + + for (let index = 0; index < state.files.length; index++) { + const file = state.files[index]; + const fileDiv = document.createElement('div'); + fileDiv.className = + 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm'; + + const infoContainer = document.createElement('div'); + infoContainer.className = 'flex flex-col overflow-hidden'; + + const nameSpan = document.createElement('div'); + nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1'; + nameSpan.textContent = file.name; + + const metaSpan = document.createElement('div'); + metaSpan.className = 'text-xs text-gray-400'; + metaSpan.textContent = formatBytes(file.size); + + 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 = () => { + state.files = state.files.filter((_, i) => i !== index); + updateUI(); + }; + + fileDiv.append(infoContainer, removeBtn); + fileDisplayArea.appendChild(fileDiv); + } + + createIcons({ icons }); + fileControls.classList.remove('hidden'); + if (convertOptions) convertOptions.classList.remove('hidden'); + (processBtn as HTMLButtonElement).disabled = false; + } else { + fileDisplayArea.innerHTML = ''; + fileControls.classList.add('hidden'); + if (convertOptions) convertOptions.classList.add('hidden'); + (processBtn as HTMLButtonElement).disabled = true; } + }; - const convertOptions = document.getElementById('convert-options'); + const resetState = () => { + state.files = []; + state.pdfDoc = null; + updateUI(); + }; - // ... (existing listeners) + const convertToPdf = async () => { + try { + if (state.files.length === 0) { + showAlert('No Files', `Please select at least one ${TOOL_NAME} file.`); + return; + } - const updateUI = async () => { - if (!fileDisplayArea || !processBtn || !fileControls) return; + showLoader('Loading engine...'); + const pymupdf = await loadPyMuPDF(); - if (state.files.length > 0) { - fileDisplayArea.innerHTML = ''; + if (state.files.length === 1) { + const originalFile = state.files[0]; + showLoader(`Converting ${originalFile.name}...`); - for (let index = 0; index < state.files.length; index++) { - const file = state.files[index]; - const fileDiv = document.createElement('div'); - fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm'; + const pdfBlob = await pymupdf.convertToPdf(originalFile, { + filetype: FILETYPE, + }); + const fileName = originalFile.name.replace(/\.[^.]+$/, '') + '.pdf'; - const infoContainer = document.createElement('div'); - infoContainer.className = 'flex flex-col overflow-hidden'; + downloadFile(pdfBlob, fileName); + hideLoader(); - const nameSpan = document.createElement('div'); - nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1'; - nameSpan.textContent = file.name; + showAlert( + 'Conversion Complete', + `Successfully converted ${originalFile.name} to PDF.`, + 'success', + () => resetState() + ); + } else { + showLoader('Converting files...'); + const JSZip = (await import('jszip')).default; + const zip = new JSZip(); - const metaSpan = document.createElement('div'); - metaSpan.className = 'text-xs text-gray-400'; - metaSpan.textContent = formatBytes(file.size); + for (let i = 0; i < state.files.length; i++) { + const file = state.files[i]; + showLoader( + `Converting ${i + 1}/${state.files.length}: ${file.name}...` + ); - 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 = () => { - state.files = state.files.filter((_, i) => i !== index); - updateUI(); - }; - - fileDiv.append(infoContainer, removeBtn); - fileDisplayArea.appendChild(fileDiv); - } - - createIcons({ icons }); - fileControls.classList.remove('hidden'); - if (convertOptions) convertOptions.classList.remove('hidden'); - (processBtn as HTMLButtonElement).disabled = false; - } else { - fileDisplayArea.innerHTML = ''; - fileControls.classList.add('hidden'); - if (convertOptions) convertOptions.classList.add('hidden'); - (processBtn as HTMLButtonElement).disabled = true; + const pdfBlob = await pymupdf.convertToPdf(file, { + filetype: FILETYPE, + }); + const baseName = file.name.replace(/\.[^.]+$/, ''); + const pdfBuffer = await pdfBlob.arrayBuffer(); + zip.file(`${baseName}.pdf`, pdfBuffer); } - }; - const resetState = () => { - state.files = []; - state.pdfDoc = null; - updateUI(); - }; + const zipBlob = await zip.generateAsync({ type: 'blob' }); + downloadFile(zipBlob, `${FILETYPE}-converted.zip`); - const convertToPdf = async () => { - try { - if (state.files.length === 0) { - showAlert('No Files', `Please select at least one ${TOOL_NAME} file.`); - return; - } + hideLoader(); - showLoader('Loading engine...'); - const pymupdf = new PyMuPDF(getWasmBaseUrl('pymupdf')); - await pymupdf.load(); + showAlert( + 'Conversion Complete', + `Successfully converted ${state.files.length} ${TOOL_NAME} file(s) to PDF.`, + 'success', + () => resetState() + ); + } + } catch (e: any) { + console.error(`[${TOOL_NAME}2PDF] ERROR:`, e); + hideLoader(); + showAlert( + 'Error', + `An error occurred during conversion. Error: ${e.message}` + ); + } + }; - if (state.files.length === 1) { - const originalFile = state.files[0]; - showLoader(`Converting ${originalFile.name}...`); + const handleFileSelect = (files: FileList | null) => { + if (files && files.length > 0) { + state.files = [...state.files, ...Array.from(files)]; + updateUI(); + } + }; - const pdfBlob = await pymupdf.convertToPdf(originalFile, { filetype: FILETYPE }); - const fileName = originalFile.name.replace(/\.[^.]+$/, '') + '.pdf'; + if (fileInput && dropZone) { + fileInput.addEventListener('change', (e) => { + handleFileSelect((e.target as HTMLInputElement).files); + }); - downloadFile(pdfBlob, fileName); - hideLoader(); + dropZone.addEventListener('dragover', (e) => { + e.preventDefault(); + dropZone.classList.add('bg-gray-700'); + }); - showAlert( - 'Conversion Complete', - `Successfully converted ${originalFile.name} to PDF.`, - 'success', - () => resetState() - ); - } else { - showLoader('Converting files...'); - const JSZip = (await import('jszip')).default; - const zip = new JSZip(); + dropZone.addEventListener('dragleave', (e) => { + e.preventDefault(); + dropZone.classList.remove('bg-gray-700'); + }); - for (let i = 0; i < state.files.length; i++) { - const file = state.files[i]; - showLoader(`Converting ${i + 1}/${state.files.length}: ${file.name}...`); - - const pdfBlob = await pymupdf.convertToPdf(file, { filetype: FILETYPE }); - const baseName = file.name.replace(/\.[^.]+$/, ''); - const pdfBuffer = await pdfBlob.arrayBuffer(); - zip.file(`${baseName}.pdf`, pdfBuffer); - } - - const zipBlob = await zip.generateAsync({ type: 'blob' }); - downloadFile(zipBlob, `${FILETYPE}-converted.zip`); - - hideLoader(); - - showAlert( - 'Conversion Complete', - `Successfully converted ${state.files.length} ${TOOL_NAME} file(s) to PDF.`, - 'success', - () => resetState() - ); - } - } catch (e: any) { - console.error(`[${TOOL_NAME}2PDF] ERROR:`, e); - hideLoader(); - showAlert('Error', `An error occurred during conversion. Error: ${e.message}`); + dropZone.addEventListener('drop', (e) => { + e.preventDefault(); + dropZone.classList.remove('bg-gray-700'); + const files = e.dataTransfer?.files; + if (files && files.length > 0) { + const validFiles = Array.from(files).filter((f) => { + const name = f.name.toLowerCase(); + return EXTENSIONS.some((ext) => name.endsWith(ext)); + }); + if (validFiles.length > 0) { + const dataTransfer = new DataTransfer(); + validFiles.forEach((f) => dataTransfer.items.add(f)); + handleFileSelect(dataTransfer.files); } - }; + } + }); - const handleFileSelect = (files: FileList | null) => { - if (files && files.length > 0) { - state.files = [...state.files, ...Array.from(files)]; - updateUI(); - } - }; + fileInput.addEventListener('click', () => { + fileInput.value = ''; + }); + } - if (fileInput && dropZone) { - fileInput.addEventListener('change', (e) => { - handleFileSelect((e.target as HTMLInputElement).files); - }); + if (addMoreBtn) { + addMoreBtn.addEventListener('click', () => { + fileInput.click(); + }); + } - dropZone.addEventListener('dragover', (e) => { - e.preventDefault(); - dropZone.classList.add('bg-gray-700'); - }); + if (clearFilesBtn) { + clearFilesBtn.addEventListener('click', () => { + resetState(); + }); + } - dropZone.addEventListener('dragleave', (e) => { - e.preventDefault(); - dropZone.classList.remove('bg-gray-700'); - }); - - dropZone.addEventListener('drop', (e) => { - e.preventDefault(); - dropZone.classList.remove('bg-gray-700'); - const files = e.dataTransfer?.files; - if (files && files.length > 0) { - const validFiles = Array.from(files).filter(f => { - const name = f.name.toLowerCase(); - return EXTENSIONS.some(ext => name.endsWith(ext)); - }); - if (validFiles.length > 0) { - const dataTransfer = new DataTransfer(); - validFiles.forEach(f => dataTransfer.items.add(f)); - handleFileSelect(dataTransfer.files); - } - } - }); - - fileInput.addEventListener('click', () => { - fileInput.value = ''; - }); - } - - if (addMoreBtn) { - addMoreBtn.addEventListener('click', () => { - fileInput.click(); - }); - } - - if (clearFilesBtn) { - clearFilesBtn.addEventListener('click', () => { - resetState(); - }); - } - - if (processBtn) { - processBtn.addEventListener('click', convertToPdf); - } + if (processBtn) { + processBtn.addEventListener('click', convertToPdf); + } }); diff --git a/src/js/logic/extract-attachments-page.ts b/src/js/logic/extract-attachments-page.ts index b8433ec..3707117 100644 --- a/src/js/logic/extract-attachments-page.ts +++ b/src/js/logic/extract-attachments-page.ts @@ -2,259 +2,297 @@ import { showAlert } from '../ui.js'; import { downloadFile, formatBytes } from '../utils/helpers.js'; import { createIcons, icons } from 'lucide'; import JSZip from 'jszip'; +import { isCpdfAvailable } from '../utils/cpdf-helper.js'; +import { + showWasmRequiredDialog, + WasmProvider, +} from '../utils/wasm-provider.js'; -const worker = new Worker(import.meta.env.BASE_URL + 'workers/extract-attachments.worker.js'); +const worker = new Worker( + import.meta.env.BASE_URL + 'workers/extract-attachments.worker.js' +); interface ExtractState { - files: File[]; + files: File[]; } const pageState: ExtractState = { - files: [], + files: [], }; function resetState() { - pageState.files = []; + pageState.files = []; - const fileDisplayArea = document.getElementById('file-display-area'); - if (fileDisplayArea) fileDisplayArea.innerHTML = ''; + const fileDisplayArea = document.getElementById('file-display-area'); + if (fileDisplayArea) fileDisplayArea.innerHTML = ''; - const toolOptions = document.getElementById('tool-options'); - if (toolOptions) toolOptions.classList.add('hidden'); + const toolOptions = document.getElementById('tool-options'); + if (toolOptions) toolOptions.classList.add('hidden'); - const statusMessage = document.getElementById('status-message'); - if (statusMessage) statusMessage.classList.add('hidden'); + const statusMessage = document.getElementById('status-message'); + if (statusMessage) statusMessage.classList.add('hidden'); - const fileInput = document.getElementById('file-input') as HTMLInputElement; - if (fileInput) fileInput.value = ''; + const fileInput = document.getElementById('file-input') as HTMLInputElement; + if (fileInput) fileInput.value = ''; - const processBtn = document.getElementById('process-btn'); - if (processBtn) { - processBtn.classList.remove('opacity-50', 'cursor-not-allowed'); - processBtn.removeAttribute('disabled'); - } + const processBtn = document.getElementById('process-btn'); + if (processBtn) { + processBtn.classList.remove('opacity-50', 'cursor-not-allowed'); + processBtn.removeAttribute('disabled'); + } } -function showStatus(message: string, type: 'success' | 'error' | 'info' = 'info') { - const statusMessage = document.getElementById('status-message') as HTMLElement; - if (!statusMessage) return; +function showStatus( + message: string, + type: 'success' | 'error' | 'info' = 'info' +) { + const statusMessage = document.getElementById( + 'status-message' + ) as HTMLElement; + if (!statusMessage) return; - statusMessage.textContent = message; - statusMessage.className = `mt-4 p-3 rounded-lg text-sm ${type === 'success' - ? 'bg-green-900 text-green-200' - : type === 'error' - ? 'bg-red-900 text-red-200' - : 'bg-blue-900 text-blue-200' - }`; - statusMessage.classList.remove('hidden'); + statusMessage.textContent = message; + statusMessage.className = `mt-4 p-3 rounded-lg text-sm ${ + type === 'success' + ? 'bg-green-900 text-green-200' + : type === 'error' + ? 'bg-red-900 text-red-200' + : 'bg-blue-900 text-blue-200' + }`; + statusMessage.classList.remove('hidden'); } worker.onmessage = function (e) { - const processBtn = document.getElementById('process-btn'); - if (processBtn) { - processBtn.classList.remove('opacity-50', 'cursor-not-allowed'); - processBtn.removeAttribute('disabled'); + const processBtn = document.getElementById('process-btn'); + if (processBtn) { + processBtn.classList.remove('opacity-50', 'cursor-not-allowed'); + processBtn.removeAttribute('disabled'); + } + + if (e.data.status === 'success') { + const attachments = e.data.attachments; + + if (attachments.length === 0) { + showAlert( + 'No Attachments', + 'The PDF file(s) do not contain any attachments to extract.' + ); + resetState(); + return; } - if (e.data.status === 'success') { - const attachments = e.data.attachments; + const zip = new JSZip(); + let totalSize = 0; - if (attachments.length === 0) { - showAlert('No Attachments', 'The PDF file(s) do not contain any attachments to extract.'); - resetState(); - return; - } - - const zip = new JSZip(); - let totalSize = 0; - - for (const attachment of attachments) { - zip.file(attachment.name, new Uint8Array(attachment.data)); - totalSize += attachment.data.byteLength; - } - - zip.generateAsync({ type: 'blob' }).then(function (zipBlob) { - downloadFile(zipBlob, 'extracted-attachments.zip'); - - showAlert('Success', `${attachments.length} attachment(s) extracted successfully!`); - - showStatus( - `Extraction completed! ${attachments.length} attachment(s) in zip file (${formatBytes(totalSize)}). Download started.`, - 'success' - ); - - resetState(); - }); - } else if (e.data.status === 'error') { - const errorMessage = e.data.message || 'Unknown error occurred in worker.'; - console.error('Worker Error:', errorMessage); - - if (errorMessage.includes('No attachments were found')) { - showAlert('No Attachments', 'The PDF file(s) do not contain any attachments to extract.'); - resetState(); - } else { - showStatus(`Error: ${errorMessage}`, 'error'); - } + for (const attachment of attachments) { + zip.file(attachment.name, new Uint8Array(attachment.data)); + totalSize += attachment.data.byteLength; } + + zip.generateAsync({ type: 'blob' }).then(function (zipBlob) { + downloadFile(zipBlob, 'extracted-attachments.zip'); + + showAlert( + 'Success', + `${attachments.length} attachment(s) extracted successfully!` + ); + + showStatus( + `Extraction completed! ${attachments.length} attachment(s) in zip file (${formatBytes(totalSize)}). Download started.`, + 'success' + ); + + resetState(); + }); + } else if (e.data.status === 'error') { + const errorMessage = e.data.message || 'Unknown error occurred in worker.'; + console.error('Worker Error:', errorMessage); + + if (errorMessage.includes('No attachments were found')) { + showAlert( + 'No Attachments', + 'The PDF file(s) do not contain any attachments to extract.' + ); + resetState(); + } else { + showStatus(`Error: ${errorMessage}`, 'error'); + } + } }; worker.onerror = function (error) { - console.error('Worker error:', error); - showStatus('Worker error occurred. Check console for details.', 'error'); + console.error('Worker error:', error); + showStatus('Worker error occurred. Check console for details.', 'error'); - const processBtn = document.getElementById('process-btn'); - if (processBtn) { - processBtn.classList.remove('opacity-50', 'cursor-not-allowed'); - processBtn.removeAttribute('disabled'); - } + const processBtn = document.getElementById('process-btn'); + if (processBtn) { + processBtn.classList.remove('opacity-50', 'cursor-not-allowed'); + processBtn.removeAttribute('disabled'); + } }; async function updateUI() { - const fileDisplayArea = document.getElementById('file-display-area'); - const toolOptions = document.getElementById('tool-options'); + const fileDisplayArea = document.getElementById('file-display-area'); + const toolOptions = document.getElementById('tool-options'); - if (!fileDisplayArea) return; + if (!fileDisplayArea) return; - fileDisplayArea.innerHTML = ''; + fileDisplayArea.innerHTML = ''; - if (pageState.files.length > 0) { - const summaryDiv = document.createElement('div'); - summaryDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm'; + if (pageState.files.length > 0) { + const summaryDiv = document.createElement('div'); + summaryDiv.className = + 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm'; - const infoContainer = document.createElement('div'); - infoContainer.className = 'flex flex-col overflow-hidden'; + const infoContainer = document.createElement('div'); + infoContainer.className = 'flex flex-col overflow-hidden'; - const countSpan = document.createElement('div'); - countSpan.className = 'font-medium text-gray-200 text-sm mb-1'; - countSpan.textContent = `${pageState.files.length} PDF file(s) selected`; + const countSpan = document.createElement('div'); + countSpan.className = 'font-medium text-gray-200 text-sm mb-1'; + countSpan.textContent = `${pageState.files.length} PDF file(s) selected`; - const sizeSpan = document.createElement('div'); - sizeSpan.className = 'text-xs text-gray-400'; - const totalSize = pageState.files.reduce(function (sum, f) { return sum + f.size; }, 0); - sizeSpan.textContent = formatBytes(totalSize); + const sizeSpan = document.createElement('div'); + sizeSpan.className = 'text-xs text-gray-400'; + const totalSize = pageState.files.reduce(function (sum, f) { + return sum + f.size; + }, 0); + sizeSpan.textContent = formatBytes(totalSize); - infoContainer.append(countSpan, sizeSpan); + infoContainer.append(countSpan, sizeSpan); - const removeBtn = document.createElement('button'); - removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0'; - removeBtn.innerHTML = ''; - removeBtn.onclick = function () { - resetState(); - }; + const removeBtn = document.createElement('button'); + removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0'; + removeBtn.innerHTML = ''; + removeBtn.onclick = function () { + resetState(); + }; - summaryDiv.append(infoContainer, removeBtn); - fileDisplayArea.appendChild(summaryDiv); - createIcons({ icons }); + summaryDiv.append(infoContainer, removeBtn); + fileDisplayArea.appendChild(summaryDiv); + createIcons({ icons }); - if (toolOptions) toolOptions.classList.remove('hidden'); - } else { - if (toolOptions) toolOptions.classList.add('hidden'); - } + if (toolOptions) toolOptions.classList.remove('hidden'); + } else { + if (toolOptions) toolOptions.classList.add('hidden'); + } } async function extractAttachments() { - if (pageState.files.length === 0) { - showStatus('No Files', 'error'); - return; + if (pageState.files.length === 0) { + showStatus('No Files', 'error'); + return; + } + + // Check if CPDF is configured + if (!isCpdfAvailable()) { + showWasmRequiredDialog('cpdf'); + return; + } + + const processBtn = document.getElementById('process-btn'); + if (processBtn) { + processBtn.classList.add('opacity-50', 'cursor-not-allowed'); + processBtn.setAttribute('disabled', 'true'); + } + + showStatus('Reading files...', 'info'); + + try { + const fileBuffers: ArrayBuffer[] = []; + const fileNames: string[] = []; + + for (const file of pageState.files) { + const buffer = await file.arrayBuffer(); + fileBuffers.push(buffer); + fileNames.push(file.name); } - const processBtn = document.getElementById('process-btn'); + showStatus( + `Extracting attachments from ${pageState.files.length} file(s)...`, + 'info' + ); + + const message = { + command: 'extract-attachments', + fileBuffers, + fileNames, + cpdfUrl: WasmProvider.getUrl('cpdf')! + 'coherentpdf.browser.min.js', + }; + + const transferables = fileBuffers.map(function (buf) { + return buf; + }); + worker.postMessage(message, transferables); + } catch (error) { + console.error('Error reading files:', error); + showStatus( + `Error reading files: ${error instanceof Error ? error.message : 'Unknown error occurred'}`, + 'error' + ); + if (processBtn) { - processBtn.classList.add('opacity-50', 'cursor-not-allowed'); - processBtn.setAttribute('disabled', 'true'); - } - - showStatus('Reading files...', 'info'); - - try { - const fileBuffers: ArrayBuffer[] = []; - const fileNames: string[] = []; - - for (const file of pageState.files) { - const buffer = await file.arrayBuffer(); - fileBuffers.push(buffer); - fileNames.push(file.name); - } - - showStatus(`Extracting attachments from ${pageState.files.length} file(s)...`, 'info'); - - const message = { - command: 'extract-attachments', - fileBuffers, - fileNames, - }; - - const transferables = fileBuffers.map(function (buf) { return buf; }); - worker.postMessage(message, transferables); - - } catch (error) { - console.error('Error reading files:', error); - showStatus( - `Error reading files: ${error instanceof Error ? error.message : 'Unknown error occurred'}`, - 'error' - ); - - if (processBtn) { - processBtn.classList.remove('opacity-50', 'cursor-not-allowed'); - processBtn.removeAttribute('disabled'); - } + processBtn.classList.remove('opacity-50', 'cursor-not-allowed'); + processBtn.removeAttribute('disabled'); } + } } function handleFileSelect(files: FileList | null) { - if (files && files.length > 0) { - const pdfFiles = Array.from(files).filter(function (f) { - return f.type === 'application/pdf' || f.name.toLowerCase().endsWith('.pdf'); - }); - if (pdfFiles.length > 0) { - pageState.files = pdfFiles; - updateUI(); - } + if (files && files.length > 0) { + const pdfFiles = Array.from(files).filter(function (f) { + return ( + f.type === 'application/pdf' || f.name.toLowerCase().endsWith('.pdf') + ); + }); + if (pdfFiles.length > 0) { + pageState.files = pdfFiles; + updateUI(); } + } } document.addEventListener('DOMContentLoaded', function () { - const fileInput = document.getElementById('file-input') as HTMLInputElement; - const dropZone = document.getElementById('drop-zone'); - const processBtn = document.getElementById('process-btn'); - const backBtn = document.getElementById('back-to-tools'); + const fileInput = document.getElementById('file-input') as HTMLInputElement; + const dropZone = document.getElementById('drop-zone'); + const processBtn = document.getElementById('process-btn'); + const backBtn = document.getElementById('back-to-tools'); - if (backBtn) { - backBtn.addEventListener('click', function () { - window.location.href = import.meta.env.BASE_URL; - }); - } + if (backBtn) { + backBtn.addEventListener('click', function () { + window.location.href = import.meta.env.BASE_URL; + }); + } - if (fileInput && dropZone) { - fileInput.addEventListener('change', function (e) { - handleFileSelect((e.target as HTMLInputElement).files); - }); + if (fileInput && dropZone) { + fileInput.addEventListener('change', function (e) { + handleFileSelect((e.target as HTMLInputElement).files); + }); - dropZone.addEventListener('dragover', function (e) { - e.preventDefault(); - dropZone.classList.add('bg-gray-700'); - }); + dropZone.addEventListener('dragover', function (e) { + e.preventDefault(); + dropZone.classList.add('bg-gray-700'); + }); - dropZone.addEventListener('dragleave', function (e) { - e.preventDefault(); - dropZone.classList.remove('bg-gray-700'); - }); + dropZone.addEventListener('dragleave', function (e) { + e.preventDefault(); + dropZone.classList.remove('bg-gray-700'); + }); - dropZone.addEventListener('drop', function (e) { - e.preventDefault(); - dropZone.classList.remove('bg-gray-700'); - const files = e.dataTransfer?.files; - if (files && files.length > 0) { - handleFileSelect(files); - } - }); + dropZone.addEventListener('drop', function (e) { + e.preventDefault(); + dropZone.classList.remove('bg-gray-700'); + const files = e.dataTransfer?.files; + if (files && files.length > 0) { + handleFileSelect(files); + } + }); - fileInput.addEventListener('click', function () { - fileInput.value = ''; - }); - } + fileInput.addEventListener('click', function () { + fileInput.value = ''; + }); + } - if (processBtn) { - processBtn.addEventListener('click', extractAttachments); - } + if (processBtn) { + processBtn.addEventListener('click', extractAttachments); + } }); diff --git a/src/js/logic/extract-images-page.ts b/src/js/logic/extract-images-page.ts index 914e24b..36d7f0b 100644 --- a/src/js/logic/extract-images-page.ts +++ b/src/js/logic/extract-images-page.ts @@ -1,281 +1,295 @@ import { showLoader, hideLoader, showAlert } from '../ui.js'; -import { downloadFile, readFileAsArrayBuffer, formatBytes, getPDFDocument } from '../utils/helpers.js'; +import { + downloadFile, + readFileAsArrayBuffer, + formatBytes, + getPDFDocument, +} from '../utils/helpers.js'; import { state } from '../state.js'; import { createIcons, icons } from 'lucide'; -import { PyMuPDF } from '@bentopdf/pymupdf-wasm'; -import { getWasmBaseUrl } from '../config/wasm-cdn-config.js'; - -const pymupdf = new PyMuPDF(getWasmBaseUrl('pymupdf')); +import { isWasmAvailable, getWasmBaseUrl } from '../config/wasm-cdn-config.js'; +import { showWasmRequiredDialog } from '../utils/wasm-provider.js'; +import { loadPyMuPDF, isPyMuPDFAvailable } from '../utils/pymupdf-loader.js'; interface ExtractedImage { - data: Uint8Array; - name: string; - ext: string; + data: Uint8Array; + name: string; + ext: string; } let extractedImages: ExtractedImage[] = []; document.addEventListener('DOMContentLoaded', () => { - const fileInput = document.getElementById('file-input') as HTMLInputElement; - const dropZone = document.getElementById('drop-zone'); - const processBtn = document.getElementById('process-btn'); - const fileDisplayArea = document.getElementById('file-display-area'); - const extractOptions = document.getElementById('extract-options'); - const fileControls = document.getElementById('file-controls'); - const addMoreBtn = document.getElementById('add-more-btn'); - const clearFilesBtn = document.getElementById('clear-files-btn'); - const backBtn = document.getElementById('back-to-tools'); - const imagesContainer = document.getElementById('images-container'); - const imagesGrid = document.getElementById('images-grid'); - const downloadAllBtn = document.getElementById('download-all-btn'); + const fileInput = document.getElementById('file-input') as HTMLInputElement; + const dropZone = document.getElementById('drop-zone'); + const processBtn = document.getElementById('process-btn'); + const fileDisplayArea = document.getElementById('file-display-area'); + const extractOptions = document.getElementById('extract-options'); + const fileControls = document.getElementById('file-controls'); + const addMoreBtn = document.getElementById('add-more-btn'); + const clearFilesBtn = document.getElementById('clear-files-btn'); + const backBtn = document.getElementById('back-to-tools'); + const imagesContainer = document.getElementById('images-container'); + const imagesGrid = document.getElementById('images-grid'); + const downloadAllBtn = document.getElementById('download-all-btn'); - if (backBtn) { - backBtn.addEventListener('click', () => { - window.location.href = import.meta.env.BASE_URL; - }); - } + if (backBtn) { + backBtn.addEventListener('click', () => { + window.location.href = import.meta.env.BASE_URL; + }); + } - const updateUI = async () => { - if (!fileDisplayArea || !extractOptions || !processBtn || !fileControls) return; + const updateUI = async () => { + if (!fileDisplayArea || !extractOptions || !processBtn || !fileControls) + return; - // Clear extracted images when files change - extractedImages = []; - if (imagesContainer) imagesContainer.classList.add('hidden'); - if (imagesGrid) imagesGrid.innerHTML = ''; + // Clear extracted images when files change + extractedImages = []; + if (imagesContainer) imagesContainer.classList.add('hidden'); + if (imagesGrid) imagesGrid.innerHTML = ''; - if (state.files.length > 0) { - fileDisplayArea.innerHTML = ''; + if (state.files.length > 0) { + fileDisplayArea.innerHTML = ''; - for (let index = 0; index < state.files.length; index++) { - const file = state.files[index]; - const fileDiv = document.createElement('div'); - fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm'; + for (let index = 0; index < state.files.length; index++) { + const file = state.files[index]; + const fileDiv = document.createElement('div'); + fileDiv.className = + 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm'; - const infoContainer = document.createElement('div'); - infoContainer.className = 'flex flex-col overflow-hidden'; + const infoContainer = document.createElement('div'); + infoContainer.className = 'flex flex-col overflow-hidden'; - const nameSpan = document.createElement('div'); - nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1'; - nameSpan.textContent = file.name; + const nameSpan = document.createElement('div'); + nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1'; + nameSpan.textContent = file.name; - const metaSpan = document.createElement('div'); - metaSpan.className = 'text-xs text-gray-400'; - metaSpan.textContent = `${formatBytes(file.size)} • Loading pages...`; + const metaSpan = document.createElement('div'); + metaSpan.className = 'text-xs text-gray-400'; + metaSpan.textContent = `${formatBytes(file.size)} • Loading pages...`; - infoContainer.append(nameSpan, metaSpan); + infoContainer.append(nameSpan, metaSpan); - const removeBtn = document.createElement('button'); - removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0'; - removeBtn.innerHTML = ''; - removeBtn.onclick = () => { - state.files = state.files.filter((_: File, i: number) => i !== index); - updateUI(); - }; + const removeBtn = document.createElement('button'); + removeBtn.className = + 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0'; + removeBtn.innerHTML = ''; + removeBtn.onclick = () => { + state.files = state.files.filter((_: File, i: number) => i !== index); + updateUI(); + }; - fileDiv.append(infoContainer, removeBtn); - fileDisplayArea.appendChild(fileDiv); + fileDiv.append(infoContainer, removeBtn); + fileDisplayArea.appendChild(fileDiv); - try { - const arrayBuffer = await readFileAsArrayBuffer(file); - const pdfDoc = await getPDFDocument({ data: arrayBuffer }).promise; - metaSpan.textContent = `${formatBytes(file.size)} • ${pdfDoc.numPages} pages`; - } catch (error) { - metaSpan.textContent = `${formatBytes(file.size)} • Could not load page count`; - } - } - - createIcons({ icons }); - fileControls.classList.remove('hidden'); - extractOptions.classList.remove('hidden'); - (processBtn as HTMLButtonElement).disabled = false; - } else { - fileDisplayArea.innerHTML = ''; - fileControls.classList.add('hidden'); - extractOptions.classList.add('hidden'); - (processBtn as HTMLButtonElement).disabled = true; - } - }; - - const resetState = () => { - state.files = []; - state.pdfDoc = null; - extractedImages = []; - if (imagesContainer) imagesContainer.classList.add('hidden'); - if (imagesGrid) imagesGrid.innerHTML = ''; - updateUI(); - }; - - const displayImages = () => { - if (!imagesGrid || !imagesContainer) return; - imagesGrid.innerHTML = ''; - - extractedImages.forEach((img, index) => { - const blob = new Blob([new Uint8Array(img.data)]); - const url = URL.createObjectURL(blob); - - const card = document.createElement('div'); - card.className = 'bg-gray-700 rounded-lg overflow-hidden'; - - const imgEl = document.createElement('img'); - imgEl.src = url; - imgEl.className = 'w-full h-32 object-cover'; - - const info = document.createElement('div'); - info.className = 'p-2 flex justify-between items-center'; - - const name = document.createElement('span'); - name.className = 'text-xs text-gray-300 truncate'; - name.textContent = img.name; - - const downloadBtn = document.createElement('button'); - downloadBtn.className = 'text-indigo-400 hover:text-indigo-300'; - downloadBtn.innerHTML = ''; - downloadBtn.onclick = () => { - downloadFile(blob, img.name); - }; - - info.append(name, downloadBtn); - card.append(imgEl, info); - imagesGrid.appendChild(card); - }); - - createIcons({ icons }); - imagesContainer.classList.remove('hidden'); - }; - - const extract = async () => { try { - if (state.files.length === 0) { - showAlert('No Files', 'Please select at least one PDF file.'); - return; - } - - showLoader('Loading PDF processor...'); - await pymupdf.load(); - - extractedImages = []; - let imgCounter = 0; - - for (let i = 0; i < state.files.length; i++) { - const file = state.files[i]; - showLoader(`Extracting images from ${file.name}...`); - - const doc = await pymupdf.open(file); - const pageCount = doc.pageCount; - - for (let pageIdx = 0; pageIdx < pageCount; pageIdx++) { - const page = doc.getPage(pageIdx); - const images = page.getImages(); - - for (const imgInfo of images) { - try { - const imgData = page.extractImage(imgInfo.xref); - if (imgData && imgData.data) { - imgCounter++; - extractedImages.push({ - data: imgData.data, - name: `image_${imgCounter}.${imgData.ext || 'png'}`, - ext: imgData.ext || 'png' - }); - } - } catch (e) { - console.warn('Failed to extract image:', e); - } - } - } - doc.close(); - } - - hideLoader(); - - if (extractedImages.length === 0) { - showAlert('No Images Found', 'No embedded images were found in the selected PDF(s).'); - } else { - displayImages(); - showAlert( - 'Extraction Complete', - `Found ${extractedImages.length} image(s) in ${state.files.length} PDF(s).`, - 'success' - ); - } - } catch (e: any) { - hideLoader(); - showAlert('Error', `An error occurred during extraction. Error: ${e.message}`); + const arrayBuffer = await readFileAsArrayBuffer(file); + const pdfDoc = await getPDFDocument({ data: arrayBuffer }).promise; + metaSpan.textContent = `${formatBytes(file.size)} • ${pdfDoc.numPages} pages`; + } catch (error) { + metaSpan.textContent = `${formatBytes(file.size)} • Could not load page count`; } - }; + } - const downloadAll = async () => { - if (extractedImages.length === 0) return; + createIcons({ icons }); + fileControls.classList.remove('hidden'); + extractOptions.classList.remove('hidden'); + (processBtn as HTMLButtonElement).disabled = false; + } else { + fileDisplayArea.innerHTML = ''; + fileControls.classList.add('hidden'); + extractOptions.classList.add('hidden'); + (processBtn as HTMLButtonElement).disabled = true; + } + }; - showLoader('Creating ZIP archive...'); - const JSZip = (await import('jszip')).default; - const zip = new JSZip(); + const resetState = () => { + state.files = []; + state.pdfDoc = null; + extractedImages = []; + if (imagesContainer) imagesContainer.classList.add('hidden'); + if (imagesGrid) imagesGrid.innerHTML = ''; + updateUI(); + }; - extractedImages.forEach((img) => { - zip.file(img.name, img.data); - }); + const displayImages = () => { + if (!imagesGrid || !imagesContainer) return; + imagesGrid.innerHTML = ''; - const zipBlob = await zip.generateAsync({ type: 'blob' }); - downloadFile(zipBlob, 'extracted-images.zip'); - hideLoader(); - }; + extractedImages.forEach((img, index) => { + const blob = new Blob([new Uint8Array(img.data)]); + const url = URL.createObjectURL(blob); - const handleFileSelect = (files: FileList | null) => { - if (files && files.length > 0) { - const pdfFiles = Array.from(files).filter( - f => f.type === 'application/pdf' || f.name.toLowerCase().endsWith('.pdf') - ); - state.files = [...state.files, ...pdfFiles]; - updateUI(); - } - }; + const card = document.createElement('div'); + card.className = 'bg-gray-700 rounded-lg overflow-hidden'; - if (fileInput && dropZone) { - fileInput.addEventListener('change', (e) => { - handleFileSelect((e.target as HTMLInputElement).files); - }); + const imgEl = document.createElement('img'); + imgEl.src = url; + imgEl.className = 'w-full h-32 object-cover'; - dropZone.addEventListener('dragover', (e) => { - e.preventDefault(); - dropZone.classList.add('bg-gray-700'); - }); + const info = document.createElement('div'); + info.className = 'p-2 flex justify-between items-center'; - dropZone.addEventListener('dragleave', (e) => { - e.preventDefault(); - dropZone.classList.remove('bg-gray-700'); - }); + const name = document.createElement('span'); + name.className = 'text-xs text-gray-300 truncate'; + name.textContent = img.name; - dropZone.addEventListener('drop', (e) => { - e.preventDefault(); - dropZone.classList.remove('bg-gray-700'); - const files = e.dataTransfer?.files; - if (files && files.length > 0) { - handleFileSelect(files); + const downloadBtn = document.createElement('button'); + downloadBtn.className = 'text-indigo-400 hover:text-indigo-300'; + downloadBtn.innerHTML = ''; + downloadBtn.onclick = () => { + downloadFile(blob, img.name); + }; + + info.append(name, downloadBtn); + card.append(imgEl, info); + imagesGrid.appendChild(card); + }); + + createIcons({ icons }); + imagesContainer.classList.remove('hidden'); + }; + + const extract = async () => { + try { + if (state.files.length === 0) { + showAlert('No Files', 'Please select at least one PDF file.'); + return; + } + + showLoader('Loading PDF processor...'); + const pymupdf = await loadPyMuPDF(); + + extractedImages = []; + let imgCounter = 0; + + for (let i = 0; i < state.files.length; i++) { + const file = state.files[i]; + showLoader(`Extracting images from ${file.name}...`); + + const doc = await pymupdf.open(file); + const pageCount = doc.pageCount; + + for (let pageIdx = 0; pageIdx < pageCount; pageIdx++) { + const page = doc.getPage(pageIdx); + const images = page.getImages(); + + for (const imgInfo of images) { + try { + const imgData = page.extractImage(imgInfo.xref); + if (imgData && imgData.data) { + imgCounter++; + extractedImages.push({ + data: imgData.data, + name: `image_${imgCounter}.${imgData.ext || 'png'}`, + ext: imgData.ext || 'png', + }); + } + } catch (e) { + console.warn('Failed to extract image:', e); } - }); + } + } + doc.close(); + } - fileInput.addEventListener('click', () => { - fileInput.value = ''; - }); - } + hideLoader(); - if (addMoreBtn) { - addMoreBtn.addEventListener('click', () => { - fileInput.click(); - }); + if (extractedImages.length === 0) { + showAlert( + 'No Images Found', + 'No embedded images were found in the selected PDF(s).' + ); + } else { + displayImages(); + showAlert( + 'Extraction Complete', + `Found ${extractedImages.length} image(s) in ${state.files.length} PDF(s).`, + 'success' + ); + } + } catch (e: any) { + hideLoader(); + showAlert( + 'Error', + `An error occurred during extraction. Error: ${e.message}` + ); } + }; - if (clearFilesBtn) { - clearFilesBtn.addEventListener('click', () => { - resetState(); - }); - } + const downloadAll = async () => { + if (extractedImages.length === 0) return; - if (processBtn) { - processBtn.addEventListener('click', extract); - } + showLoader('Creating ZIP archive...'); + const JSZip = (await import('jszip')).default; + const zip = new JSZip(); - if (downloadAllBtn) { - downloadAllBtn.addEventListener('click', downloadAll); + extractedImages.forEach((img) => { + zip.file(img.name, img.data); + }); + + const zipBlob = await zip.generateAsync({ type: 'blob' }); + downloadFile(zipBlob, 'extracted-images.zip'); + hideLoader(); + }; + + const handleFileSelect = (files: FileList | null) => { + if (files && files.length > 0) { + const pdfFiles = Array.from(files).filter( + (f) => + f.type === 'application/pdf' || f.name.toLowerCase().endsWith('.pdf') + ); + state.files = [...state.files, ...pdfFiles]; + updateUI(); } + }; + + if (fileInput && dropZone) { + fileInput.addEventListener('change', (e) => { + handleFileSelect((e.target as HTMLInputElement).files); + }); + + dropZone.addEventListener('dragover', (e) => { + e.preventDefault(); + dropZone.classList.add('bg-gray-700'); + }); + + dropZone.addEventListener('dragleave', (e) => { + e.preventDefault(); + dropZone.classList.remove('bg-gray-700'); + }); + + dropZone.addEventListener('drop', (e) => { + e.preventDefault(); + dropZone.classList.remove('bg-gray-700'); + const files = e.dataTransfer?.files; + if (files && files.length > 0) { + handleFileSelect(files); + } + }); + + fileInput.addEventListener('click', () => { + fileInput.value = ''; + }); + } + + if (addMoreBtn) { + addMoreBtn.addEventListener('click', () => { + fileInput.click(); + }); + } + + if (clearFilesBtn) { + clearFilesBtn.addEventListener('click', () => { + resetState(); + }); + } + + if (processBtn) { + processBtn.addEventListener('click', extract); + } + + if (downloadAllBtn) { + downloadAllBtn.addEventListener('click', downloadAll); + } }); diff --git a/src/js/logic/extract-tables-page.ts b/src/js/logic/extract-tables-page.ts index e25cb0b..d49d359 100644 --- a/src/js/logic/extract-tables-page.ts +++ b/src/js/logic/extract-tables-page.ts @@ -2,240 +2,259 @@ import { showLoader, hideLoader, showAlert } from '../ui.js'; import { downloadFile, formatBytes } from '../utils/helpers.js'; import { createIcons, icons } from 'lucide'; import JSZip from 'jszip'; -import { PyMuPDF } from '@bentopdf/pymupdf-wasm'; -import { getWasmBaseUrl } from '../config/wasm-cdn-config.js'; - -const pymupdf = new PyMuPDF(getWasmBaseUrl('pymupdf')); +import { isWasmAvailable, getWasmBaseUrl } from '../config/wasm-cdn-config.js'; +import { showWasmRequiredDialog } from '../utils/wasm-provider.js'; +import { loadPyMuPDF, isPyMuPDFAvailable } from '../utils/pymupdf-loader.js'; let file: File | null = null; const updateUI = () => { - const fileDisplayArea = document.getElementById('file-display-area'); - const optionsPanel = document.getElementById('options-panel'); + const fileDisplayArea = document.getElementById('file-display-area'); + const optionsPanel = document.getElementById('options-panel'); - if (!fileDisplayArea || !optionsPanel) return; + if (!fileDisplayArea || !optionsPanel) return; - fileDisplayArea.innerHTML = ''; + fileDisplayArea.innerHTML = ''; - if (file) { - optionsPanel.classList.remove('hidden'); + if (file) { + optionsPanel.classList.remove('hidden'); - const fileDiv = document.createElement('div'); - fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm'; + const fileDiv = document.createElement('div'); + fileDiv.className = + 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm'; - const infoContainer = document.createElement('div'); - infoContainer.className = 'flex flex-col overflow-hidden'; + const infoContainer = document.createElement('div'); + infoContainer.className = 'flex flex-col overflow-hidden'; - const nameSpan = document.createElement('div'); - nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1'; - nameSpan.textContent = file.name; + const nameSpan = document.createElement('div'); + nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1'; + nameSpan.textContent = file.name; - const metaSpan = document.createElement('div'); - metaSpan.className = 'text-xs text-gray-400'; - metaSpan.textContent = formatBytes(file.size); + const metaSpan = document.createElement('div'); + metaSpan.className = 'text-xs text-gray-400'; + metaSpan.textContent = formatBytes(file.size); - infoContainer.append(nameSpan, metaSpan); + infoContainer.append(nameSpan, metaSpan); - const removeBtn = document.createElement('button'); - removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0'; - removeBtn.innerHTML = ''; - removeBtn.onclick = resetState; + 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); + fileDiv.append(infoContainer, removeBtn); + fileDisplayArea.appendChild(fileDiv); - createIcons({ icons }); - } else { - optionsPanel.classList.add('hidden'); - } + createIcons({ icons }); + } else { + optionsPanel.classList.add('hidden'); + } }; const resetState = () => { - file = null; - const fileInput = document.getElementById('file-input') as HTMLInputElement; - if (fileInput) fileInput.value = ''; - updateUI(); + file = null; + const fileInput = document.getElementById('file-input') as HTMLInputElement; + if (fileInput) fileInput.value = ''; + updateUI(); }; function tableToCsv(rows: (string | null)[][]): string { - return rows.map(row => - row.map(cell => { - const cellStr = cell ?? ''; - if (cellStr.includes(',') || cellStr.includes('"') || cellStr.includes('\n')) { - return `"${cellStr.replace(/"/g, '""')}"`; - } - return cellStr; - }).join(',') - ).join('\n'); + return rows + .map((row) => + row + .map((cell) => { + const cellStr = cell ?? ''; + if ( + cellStr.includes(',') || + cellStr.includes('"') || + cellStr.includes('\n') + ) { + return `"${cellStr.replace(/"/g, '""')}"`; + } + return cellStr; + }) + .join(',') + ) + .join('\n'); } async function extract() { - if (!file) { - showAlert('No File', 'Please upload a PDF file first.'); - return; + if (!file) { + showAlert('No File', 'Please upload a PDF file first.'); + return; + } + + const formatRadios = document.querySelectorAll('input[name="export-format"]'); + let format = 'csv'; + formatRadios.forEach((radio: Element) => { + if ((radio as HTMLInputElement).checked) { + format = (radio as HTMLInputElement).value; } + }); - const formatRadios = document.querySelectorAll('input[name="export-format"]'); - let format = 'csv'; - formatRadios.forEach((radio: Element) => { - if ((radio as HTMLInputElement).checked) { - format = (radio as HTMLInputElement).value; - } - }); - + try { showLoader('Loading Engine...'); + const pymupdf = await loadPyMuPDF(); + showLoader('Extracting tables...'); - try { - await pymupdf.load(); - showLoader('Extracting tables...'); + const doc = await pymupdf.open(file); + const pageCount = doc.pageCount; + const baseName = file.name.replace(/\.[^/.]+$/, ''); - const doc = await pymupdf.open(file); - const pageCount = doc.pageCount; - const baseName = file.name.replace(/\.[^/.]+$/, ''); - - interface TableData { - page: number; - tableIndex: number; - rows: (string | null)[][]; - markdown: string; - rowCount: number; - colCount: number; - } - - const allTables: TableData[] = []; - - for (let i = 0; i < pageCount; i++) { - showLoader(`Scanning page ${i + 1} of ${pageCount}...`); - const page = doc.getPage(i); - const tables = page.findTables(); - - tables.forEach((table, tableIdx) => { - allTables.push({ - page: i + 1, - tableIndex: tableIdx + 1, - rows: table.rows, - markdown: table.markdown, - rowCount: table.rowCount, - colCount: table.colCount - }); - }); - } - - if (allTables.length === 0) { - showAlert('No Tables Found', 'No tables were detected in this PDF.'); - return; - } - - if (allTables.length === 1) { - const table = allTables[0]; - let content: string; - let ext: string; - let mimeType: string; - - if (format === 'csv') { - content = tableToCsv(table.rows); - ext = 'csv'; - mimeType = 'text/csv'; - } else if (format === 'json') { - content = JSON.stringify(table.rows, null, 2); - ext = 'json'; - mimeType = 'application/json'; - } else { - content = table.markdown; - ext = 'md'; - mimeType = 'text/markdown'; - } - - const blob = new Blob([content], { type: mimeType }); - downloadFile(blob, `${baseName}_table.${ext}`); - showAlert('Success', `Extracted 1 table successfully!`, 'success', resetState); - } else { - showLoader('Creating ZIP file...'); - const zip = new JSZip(); - - allTables.forEach((table, idx) => { - const filename = `table_${idx + 1}_page${table.page}`; - let content: string; - let ext: string; - - if (format === 'csv') { - content = tableToCsv(table.rows); - ext = 'csv'; - } else if (format === 'json') { - content = JSON.stringify(table.rows, null, 2); - ext = 'json'; - } else { - content = table.markdown; - ext = 'md'; - } - - zip.file(`${filename}.${ext}`, content); - }); - - const zipBlob = await zip.generateAsync({ type: 'blob' }); - downloadFile(zipBlob, `${baseName}_tables.zip`); - showAlert('Success', `Extracted ${allTables.length} tables successfully!`, 'success', resetState); - } - } catch (e) { - console.error(e); - const message = e instanceof Error ? e.message : 'Unknown error'; - showAlert('Error', `Failed to extract tables. ${message}`); - } finally { - hideLoader(); + interface TableData { + page: number; + tableIndex: number; + rows: (string | null)[][]; + markdown: string; + rowCount: number; + colCount: number; } + + const allTables: TableData[] = []; + + for (let i = 0; i < pageCount; i++) { + showLoader(`Scanning page ${i + 1} of ${pageCount}...`); + const page = doc.getPage(i); + const tables = page.findTables(); + + tables.forEach((table, tableIdx) => { + allTables.push({ + page: i + 1, + tableIndex: tableIdx + 1, + rows: table.rows, + markdown: table.markdown, + rowCount: table.rowCount, + colCount: table.colCount, + }); + }); + } + + if (allTables.length === 0) { + showAlert('No Tables Found', 'No tables were detected in this PDF.'); + return; + } + + if (allTables.length === 1) { + const table = allTables[0]; + let content: string; + let ext: string; + let mimeType: string; + + if (format === 'csv') { + content = tableToCsv(table.rows); + ext = 'csv'; + mimeType = 'text/csv'; + } else if (format === 'json') { + content = JSON.stringify(table.rows, null, 2); + ext = 'json'; + mimeType = 'application/json'; + } else { + content = table.markdown; + ext = 'md'; + mimeType = 'text/markdown'; + } + + const blob = new Blob([content], { type: mimeType }); + downloadFile(blob, `${baseName}_table.${ext}`); + showAlert( + 'Success', + `Extracted 1 table successfully!`, + 'success', + resetState + ); + } else { + showLoader('Creating ZIP file...'); + const zip = new JSZip(); + + allTables.forEach((table, idx) => { + const filename = `table_${idx + 1}_page${table.page}`; + let content: string; + let ext: string; + + if (format === 'csv') { + content = tableToCsv(table.rows); + ext = 'csv'; + } else if (format === 'json') { + content = JSON.stringify(table.rows, null, 2); + ext = 'json'; + } else { + content = table.markdown; + ext = 'md'; + } + + zip.file(`${filename}.${ext}`, content); + }); + + const zipBlob = await zip.generateAsync({ type: 'blob' }); + downloadFile(zipBlob, `${baseName}_tables.zip`); + showAlert( + 'Success', + `Extracted ${allTables.length} tables successfully!`, + 'success', + resetState + ); + } + } catch (e) { + console.error(e); + const message = e instanceof Error ? e.message : 'Unknown error'; + showAlert('Error', `Failed to extract tables. ${message}`); + } finally { + hideLoader(); + } } document.addEventListener('DOMContentLoaded', () => { - const fileInput = document.getElementById('file-input') as HTMLInputElement; - const dropZone = document.getElementById('drop-zone'); - const processBtn = document.getElementById('process-btn'); - const backBtn = document.getElementById('back-to-tools'); + const fileInput = document.getElementById('file-input') as HTMLInputElement; + const dropZone = document.getElementById('drop-zone'); + const processBtn = document.getElementById('process-btn'); + const backBtn = document.getElementById('back-to-tools'); - if (backBtn) { - backBtn.addEventListener('click', () => { - window.location.href = import.meta.env.BASE_URL; - }); + if (backBtn) { + backBtn.addEventListener('click', () => { + window.location.href = import.meta.env.BASE_URL; + }); + } + + const handleFileSelect = (newFiles: FileList | null) => { + if (!newFiles || newFiles.length === 0) return; + const validFile = Array.from(newFiles).find( + (f) => f.type === 'application/pdf' + ); + + if (!validFile) { + showAlert('Invalid File', 'Please upload a PDF file.'); + return; } - const handleFileSelect = (newFiles: FileList | null) => { - if (!newFiles || newFiles.length === 0) return; - const validFile = Array.from(newFiles).find(f => f.type === 'application/pdf'); + file = validFile; + updateUI(); + }; - if (!validFile) { - showAlert('Invalid File', 'Please upload a PDF file.'); - return; - } + if (fileInput && dropZone) { + fileInput.addEventListener('change', (e) => { + handleFileSelect((e.target as HTMLInputElement).files); + }); - file = validFile; - updateUI(); - }; + dropZone.addEventListener('dragover', (e) => { + e.preventDefault(); + dropZone.classList.add('bg-gray-700'); + }); - if (fileInput && dropZone) { - fileInput.addEventListener('change', (e) => { - handleFileSelect((e.target as HTMLInputElement).files); - }); + dropZone.addEventListener('dragleave', (e) => { + e.preventDefault(); + dropZone.classList.remove('bg-gray-700'); + }); - dropZone.addEventListener('dragover', (e) => { - e.preventDefault(); - dropZone.classList.add('bg-gray-700'); - }); + dropZone.addEventListener('drop', (e) => { + e.preventDefault(); + dropZone.classList.remove('bg-gray-700'); + handleFileSelect(e.dataTransfer?.files ?? null); + }); - dropZone.addEventListener('dragleave', (e) => { - e.preventDefault(); - dropZone.classList.remove('bg-gray-700'); - }); + fileInput.addEventListener('click', () => { + fileInput.value = ''; + }); + } - dropZone.addEventListener('drop', (e) => { - e.preventDefault(); - dropZone.classList.remove('bg-gray-700'); - handleFileSelect(e.dataTransfer?.files ?? null); - }); - - fileInput.addEventListener('click', () => { - fileInput.value = ''; - }); - } - - if (processBtn) { - processBtn.addEventListener('click', extract); - } + if (processBtn) { + processBtn.addEventListener('click', extract); + } }); diff --git a/src/js/logic/fb2-to-pdf-page.ts b/src/js/logic/fb2-to-pdf-page.ts index 109002f..8058460 100644 --- a/src/js/logic/fb2-to-pdf-page.ts +++ b/src/js/logic/fb2-to-pdf-page.ts @@ -2,201 +2,212 @@ import { showLoader, hideLoader, showAlert } from '../ui.js'; import { downloadFile, formatBytes } from '../utils/helpers.js'; import { state } from '../state.js'; import { createIcons, icons } from 'lucide'; -import { PyMuPDF } from '@bentopdf/pymupdf-wasm'; -import { getWasmBaseUrl } from '../config/wasm-cdn-config.js'; +import { isWasmAvailable, getWasmBaseUrl } from '../config/wasm-cdn-config.js'; +import { showWasmRequiredDialog } from '../utils/wasm-provider.js'; +import { loadPyMuPDF, isPyMuPDFAvailable } from '../utils/pymupdf-loader.js'; const FILETYPE = 'fb2'; const EXTENSIONS = ['.fb2']; const TOOL_NAME = 'FB2'; document.addEventListener('DOMContentLoaded', () => { - const fileInput = document.getElementById('file-input') as HTMLInputElement; - const dropZone = document.getElementById('drop-zone'); - const processBtn = document.getElementById('process-btn'); - const fileDisplayArea = document.getElementById('file-display-area'); - const fileControls = document.getElementById('file-controls'); - const addMoreBtn = document.getElementById('add-more-btn'); - const clearFilesBtn = document.getElementById('clear-files-btn'); - const backBtn = document.getElementById('back-to-tools'); + const fileInput = document.getElementById('file-input') as HTMLInputElement; + const dropZone = document.getElementById('drop-zone'); + const processBtn = document.getElementById('process-btn'); + const fileDisplayArea = document.getElementById('file-display-area'); + const fileControls = document.getElementById('file-controls'); + const addMoreBtn = document.getElementById('add-more-btn'); + const clearFilesBtn = document.getElementById('clear-files-btn'); + const backBtn = document.getElementById('back-to-tools'); - if (backBtn) { - backBtn.addEventListener('click', () => { - window.location.href = import.meta.env.BASE_URL; - }); + if (backBtn) { + backBtn.addEventListener('click', () => { + window.location.href = import.meta.env.BASE_URL; + }); + } + + const updateUI = async () => { + if (!fileDisplayArea || !processBtn || !fileControls) return; + + if (state.files.length > 0) { + fileDisplayArea.innerHTML = ''; + + for (let index = 0; index < state.files.length; index++) { + const file = state.files[index]; + const fileDiv = document.createElement('div'); + fileDiv.className = + 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm'; + + const infoContainer = document.createElement('div'); + infoContainer.className = 'flex flex-col overflow-hidden'; + + const nameSpan = document.createElement('div'); + nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1'; + nameSpan.textContent = file.name; + + const metaSpan = document.createElement('div'); + metaSpan.className = 'text-xs text-gray-400'; + metaSpan.textContent = formatBytes(file.size); + + 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 = () => { + state.files = state.files.filter((_, i) => i !== index); + updateUI(); + }; + + fileDiv.append(infoContainer, removeBtn); + fileDisplayArea.appendChild(fileDiv); + } + + createIcons({ icons }); + fileControls.classList.remove('hidden'); + processBtn.classList.remove('hidden'); + (processBtn as HTMLButtonElement).disabled = false; + } else { + fileDisplayArea.innerHTML = ''; + fileControls.classList.add('hidden'); + processBtn.classList.add('hidden'); + (processBtn as HTMLButtonElement).disabled = true; } + }; - const updateUI = async () => { - if (!fileDisplayArea || !processBtn || !fileControls) return; + const resetState = () => { + state.files = []; + state.pdfDoc = null; + updateUI(); + }; - if (state.files.length > 0) { - fileDisplayArea.innerHTML = ''; + const convertToPdf = async () => { + try { + if (state.files.length === 0) { + showAlert('No Files', `Please select at least one ${TOOL_NAME} file.`); + return; + } - for (let index = 0; index < state.files.length; index++) { - const file = state.files[index]; - const fileDiv = document.createElement('div'); - fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm'; + showLoader('Loading engine...'); + const pymupdf = await loadPyMuPDF(); - const infoContainer = document.createElement('div'); - infoContainer.className = 'flex flex-col overflow-hidden'; + if (state.files.length === 1) { + const originalFile = state.files[0]; + showLoader(`Converting ${originalFile.name}...`); - const nameSpan = document.createElement('div'); - nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1'; - nameSpan.textContent = file.name; + const pdfBlob = await pymupdf.convertToPdf(originalFile, { + filetype: FILETYPE, + }); + const fileName = originalFile.name.replace(/\.[^.]+$/, '') + '.pdf'; - const metaSpan = document.createElement('div'); - metaSpan.className = 'text-xs text-gray-400'; - metaSpan.textContent = formatBytes(file.size); + downloadFile(pdfBlob, fileName); + hideLoader(); - infoContainer.append(nameSpan, metaSpan); + showAlert( + 'Conversion Complete', + `Successfully converted ${originalFile.name} to PDF.`, + 'success', + () => resetState() + ); + } else { + showLoader('Converting files...'); + const JSZip = (await import('jszip')).default; + const zip = new JSZip(); - const removeBtn = document.createElement('button'); - removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0'; - removeBtn.innerHTML = ''; - removeBtn.onclick = () => { - state.files = state.files.filter((_, i) => i !== index); - updateUI(); - }; + for (let i = 0; i < state.files.length; i++) { + const file = state.files[i]; + showLoader( + `Converting ${i + 1}/${state.files.length}: ${file.name}...` + ); - fileDiv.append(infoContainer, removeBtn); - fileDisplayArea.appendChild(fileDiv); - } - - createIcons({ icons }); - fileControls.classList.remove('hidden'); - processBtn.classList.remove('hidden'); - (processBtn as HTMLButtonElement).disabled = false; - } else { - fileDisplayArea.innerHTML = ''; - fileControls.classList.add('hidden'); - processBtn.classList.add('hidden'); - (processBtn as HTMLButtonElement).disabled = true; + const pdfBlob = await pymupdf.convertToPdf(file, { + filetype: FILETYPE, + }); + const baseName = file.name.replace(/\.[^.]+$/, ''); + const pdfBuffer = await pdfBlob.arrayBuffer(); + zip.file(`${baseName}.pdf`, pdfBuffer); } - }; - const resetState = () => { - state.files = []; - state.pdfDoc = null; - updateUI(); - }; + const zipBlob = await zip.generateAsync({ type: 'blob' }); + downloadFile(zipBlob, `${FILETYPE}-converted.zip`); - const convertToPdf = async () => { - try { - if (state.files.length === 0) { - showAlert('No Files', `Please select at least one ${TOOL_NAME} file.`); - return; - } + hideLoader(); - showLoader('Loading engine...'); - const pymupdf = new PyMuPDF(getWasmBaseUrl('pymupdf')); - await pymupdf.load(); + showAlert( + 'Conversion Complete', + `Successfully converted ${state.files.length} ${TOOL_NAME} file(s) to PDF.`, + 'success', + () => resetState() + ); + } + } catch (e: any) { + console.error(`[${TOOL_NAME}2PDF] ERROR:`, e); + hideLoader(); + showAlert( + 'Error', + `An error occurred during conversion. Error: ${e.message}` + ); + } + }; - if (state.files.length === 1) { - const originalFile = state.files[0]; - showLoader(`Converting ${originalFile.name}...`); + const handleFileSelect = (files: FileList | null) => { + if (files && files.length > 0) { + state.files = [...state.files, ...Array.from(files)]; + updateUI(); + } + }; - const pdfBlob = await pymupdf.convertToPdf(originalFile, { filetype: FILETYPE }); - const fileName = originalFile.name.replace(/\.[^.]+$/, '') + '.pdf'; + if (fileInput && dropZone) { + fileInput.addEventListener('change', (e) => { + handleFileSelect((e.target as HTMLInputElement).files); + }); - downloadFile(pdfBlob, fileName); - hideLoader(); + dropZone.addEventListener('dragover', (e) => { + e.preventDefault(); + dropZone.classList.add('bg-gray-700'); + }); - showAlert( - 'Conversion Complete', - `Successfully converted ${originalFile.name} to PDF.`, - 'success', - () => resetState() - ); - } else { - showLoader('Converting files...'); - const JSZip = (await import('jszip')).default; - const zip = new JSZip(); + dropZone.addEventListener('dragleave', (e) => { + e.preventDefault(); + dropZone.classList.remove('bg-gray-700'); + }); - for (let i = 0; i < state.files.length; i++) { - const file = state.files[i]; - showLoader(`Converting ${i + 1}/${state.files.length}: ${file.name}...`); - - const pdfBlob = await pymupdf.convertToPdf(file, { filetype: FILETYPE }); - const baseName = file.name.replace(/\.[^.]+$/, ''); - const pdfBuffer = await pdfBlob.arrayBuffer(); - zip.file(`${baseName}.pdf`, pdfBuffer); - } - - const zipBlob = await zip.generateAsync({ type: 'blob' }); - downloadFile(zipBlob, `${FILETYPE}-converted.zip`); - - hideLoader(); - - showAlert( - 'Conversion Complete', - `Successfully converted ${state.files.length} ${TOOL_NAME} file(s) to PDF.`, - 'success', - () => resetState() - ); - } - } catch (e: any) { - console.error(`[${TOOL_NAME}2PDF] ERROR:`, e); - hideLoader(); - showAlert('Error', `An error occurred during conversion. Error: ${e.message}`); + dropZone.addEventListener('drop', (e) => { + e.preventDefault(); + dropZone.classList.remove('bg-gray-700'); + const files = e.dataTransfer?.files; + if (files && files.length > 0) { + const validFiles = Array.from(files).filter((f) => { + const name = f.name.toLowerCase(); + return EXTENSIONS.some((ext) => name.endsWith(ext)); + }); + if (validFiles.length > 0) { + const dataTransfer = new DataTransfer(); + validFiles.forEach((f) => dataTransfer.items.add(f)); + handleFileSelect(dataTransfer.files); } - }; + } + }); - const handleFileSelect = (files: FileList | null) => { - if (files && files.length > 0) { - state.files = [...state.files, ...Array.from(files)]; - updateUI(); - } - }; + fileInput.addEventListener('click', () => { + fileInput.value = ''; + }); + } - if (fileInput && dropZone) { - fileInput.addEventListener('change', (e) => { - handleFileSelect((e.target as HTMLInputElement).files); - }); + if (addMoreBtn) { + addMoreBtn.addEventListener('click', () => { + fileInput.click(); + }); + } - dropZone.addEventListener('dragover', (e) => { - e.preventDefault(); - dropZone.classList.add('bg-gray-700'); - }); + if (clearFilesBtn) { + clearFilesBtn.addEventListener('click', () => { + resetState(); + }); + } - dropZone.addEventListener('dragleave', (e) => { - e.preventDefault(); - dropZone.classList.remove('bg-gray-700'); - }); - - dropZone.addEventListener('drop', (e) => { - e.preventDefault(); - dropZone.classList.remove('bg-gray-700'); - const files = e.dataTransfer?.files; - if (files && files.length > 0) { - const validFiles = Array.from(files).filter(f => { - const name = f.name.toLowerCase(); - return EXTENSIONS.some(ext => name.endsWith(ext)); - }); - if (validFiles.length > 0) { - const dataTransfer = new DataTransfer(); - validFiles.forEach(f => dataTransfer.items.add(f)); - handleFileSelect(dataTransfer.files); - } - } - }); - - fileInput.addEventListener('click', () => { - fileInput.value = ''; - }); - } - - if (addMoreBtn) { - addMoreBtn.addEventListener('click', () => { - fileInput.click(); - }); - } - - if (clearFilesBtn) { - clearFilesBtn.addEventListener('click', () => { - resetState(); - }); - } - - if (processBtn) { - processBtn.addEventListener('click', convertToPdf); - } + if (processBtn) { + processBtn.addEventListener('click', convertToPdf); + } }); diff --git a/src/js/logic/fix-page-size-page.ts b/src/js/logic/fix-page-size-page.ts index 76c54d4..976c771 100644 --- a/src/js/logic/fix-page-size-page.ts +++ b/src/js/logic/fix-page-size-page.ts @@ -1,230 +1,218 @@ import { showAlert } from '../ui.js'; import { downloadFile, formatBytes, hexToRgb } from '../utils/helpers.js'; -import { PDFDocument as PDFLibDocument, rgb, PageSizes } from 'pdf-lib'; +import { fixPageSize as fixPageSizeCore } from '../utils/pdf-operations'; import { icons, createIcons } from 'lucide'; import { FixPageSizeState } from '@/types'; const pageState: FixPageSizeState = { - file: null, + file: null, }; function resetState() { - pageState.file = null; + pageState.file = null; - const fileDisplayArea = document.getElementById('file-display-area'); - if (fileDisplayArea) fileDisplayArea.innerHTML = ''; + const fileDisplayArea = document.getElementById('file-display-area'); + if (fileDisplayArea) fileDisplayArea.innerHTML = ''; - const toolOptions = document.getElementById('tool-options'); - if (toolOptions) toolOptions.classList.add('hidden'); + const toolOptions = document.getElementById('tool-options'); + if (toolOptions) toolOptions.classList.add('hidden'); - const fileInput = document.getElementById('file-input') as HTMLInputElement; - if (fileInput) fileInput.value = ''; + const fileInput = document.getElementById('file-input') as HTMLInputElement; + if (fileInput) fileInput.value = ''; } async function updateUI() { - const fileDisplayArea = document.getElementById('file-display-area'); - const toolOptions = document.getElementById('tool-options'); + const fileDisplayArea = document.getElementById('file-display-area'); + const toolOptions = document.getElementById('tool-options'); - if (!fileDisplayArea) return; + if (!fileDisplayArea) return; - fileDisplayArea.innerHTML = ''; + fileDisplayArea.innerHTML = ''; - if (pageState.file) { - const fileDiv = document.createElement('div'); - fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm'; + if (pageState.file) { + const fileDiv = document.createElement('div'); + fileDiv.className = + 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm'; - const infoContainer = document.createElement('div'); - infoContainer.className = 'flex flex-col overflow-hidden'; + const infoContainer = document.createElement('div'); + infoContainer.className = 'flex flex-col overflow-hidden'; - const nameSpan = document.createElement('div'); - nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1'; - nameSpan.textContent = pageState.file.name; + const nameSpan = document.createElement('div'); + nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1'; + nameSpan.textContent = pageState.file.name; - const metaSpan = document.createElement('div'); - metaSpan.className = 'text-xs text-gray-400'; - metaSpan.textContent = formatBytes(pageState.file.size); + const metaSpan = document.createElement('div'); + metaSpan.className = 'text-xs text-gray-400'; + metaSpan.textContent = formatBytes(pageState.file.size); - infoContainer.append(nameSpan, metaSpan); + infoContainer.append(nameSpan, metaSpan); - const removeBtn = document.createElement('button'); - removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0'; - removeBtn.innerHTML = ''; - removeBtn.onclick = function () { - resetState(); - }; + const removeBtn = document.createElement('button'); + removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0'; + removeBtn.innerHTML = ''; + removeBtn.onclick = function () { + resetState(); + }; - fileDiv.append(infoContainer, removeBtn); - fileDisplayArea.appendChild(fileDiv); - createIcons({ icons }); + fileDiv.append(infoContainer, removeBtn); + fileDisplayArea.appendChild(fileDiv); + createIcons({ icons }); - if (toolOptions) toolOptions.classList.remove('hidden'); - } else { - if (toolOptions) toolOptions.classList.add('hidden'); - } + if (toolOptions) toolOptions.classList.remove('hidden'); + } else { + if (toolOptions) toolOptions.classList.add('hidden'); + } } function handleFileSelect(files: FileList | null) { - if (files && files.length > 0) { - const file = files[0]; - if (file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf')) { - pageState.file = file; - updateUI(); - } + if (files && files.length > 0) { + const file = files[0]; + if ( + file.type === 'application/pdf' || + file.name.toLowerCase().endsWith('.pdf') + ) { + pageState.file = file; + updateUI(); } + } } async function fixPageSize() { - if (!pageState.file) { - showAlert('No File', 'Please upload a PDF file first.'); - return; - } + if (!pageState.file) { + showAlert('No File', 'Please upload a PDF file first.'); + return; + } - const targetSizeKey = (document.getElementById('target-size') as HTMLSelectElement).value; - const orientation = (document.getElementById('orientation') as HTMLSelectElement).value; - const scalingMode = (document.querySelector('input[name="scaling-mode"]:checked') as HTMLInputElement).value; - const backgroundColor = hexToRgb((document.getElementById('background-color') as HTMLInputElement).value); + const targetSize = ( + document.getElementById('target-size') as HTMLSelectElement + ).value; + const orientation = ( + document.getElementById('orientation') as HTMLSelectElement + ).value; + const scalingMode = ( + document.querySelector( + 'input[name="scaling-mode"]:checked' + ) as HTMLInputElement + ).value; + const backgroundColor = hexToRgb( + (document.getElementById('background-color') as HTMLInputElement).value + ); - const loaderModal = document.getElementById('loader-modal'); - const loaderText = document.getElementById('loader-text'); - if (loaderModal) loaderModal.classList.remove('hidden'); - if (loaderText) loaderText.textContent = 'Standardizing pages...'; + const loaderModal = document.getElementById('loader-modal'); + const loaderText = document.getElementById('loader-text'); + if (loaderModal) loaderModal.classList.remove('hidden'); + if (loaderText) loaderText.textContent = 'Standardizing pages...'; - try { - let targetWidth, targetHeight; + try { + const customWidth = + parseFloat( + (document.getElementById('custom-width') as HTMLInputElement)?.value + ) || 210; + const customHeight = + parseFloat( + (document.getElementById('custom-height') as HTMLInputElement)?.value + ) || 297; + const customUnits = + (document.getElementById('custom-units') as HTMLSelectElement)?.value || + 'mm'; - if (targetSizeKey === 'Custom') { - const width = parseFloat((document.getElementById('custom-width') as HTMLInputElement).value); - const height = parseFloat((document.getElementById('custom-height') as HTMLInputElement).value); - const units = (document.getElementById('custom-units') as HTMLSelectElement).value; + const arrayBuffer = await pageState.file.arrayBuffer(); + const pdfBytes = new Uint8Array(arrayBuffer); - if (units === 'in') { - targetWidth = width * 72; - targetHeight = height * 72; - } else { - // mm - targetWidth = width * (72 / 25.4); - targetHeight = height * (72 / 25.4); - } - } else { - [targetWidth, targetHeight] = PageSizes[targetSizeKey as keyof typeof PageSizes]; - } + const newPdfBytes = await fixPageSizeCore(pdfBytes, { + targetSize, + orientation, + scalingMode, + backgroundColor, + customWidth, + customHeight, + customUnits, + }); - if (orientation === 'landscape' && targetWidth < targetHeight) { - [targetWidth, targetHeight] = [targetHeight, targetWidth]; - } else if (orientation === 'portrait' && targetWidth > targetHeight) { - [targetWidth, targetHeight] = [targetHeight, targetWidth]; - } - - const arrayBuffer = await pageState.file.arrayBuffer(); - const sourceDoc = await PDFLibDocument.load(arrayBuffer); - const newDoc = await PDFLibDocument.create(); - - for (const sourcePage of sourceDoc.getPages()) { - const { width: sourceWidth, height: sourceHeight } = sourcePage.getSize(); - const embeddedPage = await newDoc.embedPage(sourcePage); - - const newPage = newDoc.addPage([targetWidth, targetHeight]); - newPage.drawRectangle({ - x: 0, - y: 0, - width: targetWidth, - height: targetHeight, - color: rgb(backgroundColor.r, backgroundColor.g, backgroundColor.b), - }); - - const scaleX = targetWidth / sourceWidth; - const scaleY = targetHeight / sourceHeight; - const scale = scalingMode === 'fit' ? Math.min(scaleX, scaleY) : Math.max(scaleX, scaleY); - - const scaledWidth = sourceWidth * scale; - const scaledHeight = sourceHeight * scale; - - const x = (targetWidth - scaledWidth) / 2; - const y = (targetHeight - scaledHeight) / 2; - - newPage.drawPage(embeddedPage, { - x, - y, - width: scaledWidth, - height: scaledHeight, - }); - } - - const newPdfBytes = await newDoc.save(); - downloadFile( - new Blob([new Uint8Array(newPdfBytes)], { type: 'application/pdf' }), - 'standardized.pdf' - ); - showAlert('Success', 'Page sizes standardized successfully!', 'success', () => { resetState(); }); - } catch (e) { - console.error(e); - showAlert('Error', 'An error occurred while standardizing pages.'); - } finally { - if (loaderModal) loaderModal.classList.add('hidden'); - } + downloadFile( + new Blob([new Uint8Array(newPdfBytes)], { type: 'application/pdf' }), + 'standardized.pdf' + ); + showAlert( + 'Success', + 'Page sizes standardized successfully!', + 'success', + () => { + resetState(); + } + ); + } catch (e) { + console.error(e); + showAlert('Error', 'An error occurred while standardizing pages.'); + } finally { + if (loaderModal) loaderModal.classList.add('hidden'); + } } document.addEventListener('DOMContentLoaded', function () { - const fileInput = document.getElementById('file-input') as HTMLInputElement; - const dropZone = document.getElementById('drop-zone'); - const processBtn = document.getElementById('process-btn'); - const backBtn = document.getElementById('back-to-tools'); - const targetSizeSelect = document.getElementById('target-size'); - const customSizeWrapper = document.getElementById('custom-size-wrapper'); + const fileInput = document.getElementById('file-input') as HTMLInputElement; + const dropZone = document.getElementById('drop-zone'); + const processBtn = document.getElementById('process-btn'); + const backBtn = document.getElementById('back-to-tools'); + const targetSizeSelect = document.getElementById('target-size'); + const customSizeWrapper = document.getElementById('custom-size-wrapper'); - if (backBtn) { - backBtn.addEventListener('click', function () { - window.location.href = import.meta.env.BASE_URL; + if (backBtn) { + backBtn.addEventListener('click', function () { + window.location.href = import.meta.env.BASE_URL; + }); + } + + // Setup custom size toggle + if (targetSizeSelect && customSizeWrapper) { + targetSizeSelect.addEventListener('change', function () { + customSizeWrapper.classList.toggle( + 'hidden', + (targetSizeSelect as HTMLSelectElement).value !== 'Custom' + ); + }); + } + + if (fileInput && dropZone) { + fileInput.addEventListener('change', function (e) { + handleFileSelect((e.target as HTMLInputElement).files); + }); + + dropZone.addEventListener('dragover', function (e) { + e.preventDefault(); + dropZone.classList.add('bg-gray-700'); + }); + + dropZone.addEventListener('dragleave', function (e) { + e.preventDefault(); + dropZone.classList.remove('bg-gray-700'); + }); + + dropZone.addEventListener('drop', function (e) { + e.preventDefault(); + dropZone.classList.remove('bg-gray-700'); + const files = e.dataTransfer?.files; + if (files && files.length > 0) { + const pdfFiles = Array.from(files).filter(function (f) { + return ( + f.type === 'application/pdf' || + f.name.toLowerCase().endsWith('.pdf') + ); }); - } + if (pdfFiles.length > 0) { + const dataTransfer = new DataTransfer(); + dataTransfer.items.add(pdfFiles[0]); + handleFileSelect(dataTransfer.files); + } + } + }); - // Setup custom size toggle - if (targetSizeSelect && customSizeWrapper) { - targetSizeSelect.addEventListener('change', function () { - customSizeWrapper.classList.toggle( - 'hidden', - (targetSizeSelect as HTMLSelectElement).value !== 'Custom' - ); - }); - } + fileInput.addEventListener('click', function () { + fileInput.value = ''; + }); + } - if (fileInput && dropZone) { - fileInput.addEventListener('change', function (e) { - handleFileSelect((e.target as HTMLInputElement).files); - }); - - dropZone.addEventListener('dragover', function (e) { - e.preventDefault(); - dropZone.classList.add('bg-gray-700'); - }); - - dropZone.addEventListener('dragleave', function (e) { - e.preventDefault(); - dropZone.classList.remove('bg-gray-700'); - }); - - dropZone.addEventListener('drop', function (e) { - e.preventDefault(); - dropZone.classList.remove('bg-gray-700'); - const files = e.dataTransfer?.files; - if (files && files.length > 0) { - const pdfFiles = Array.from(files).filter(function (f) { - return f.type === 'application/pdf' || f.name.toLowerCase().endsWith('.pdf'); - }); - if (pdfFiles.length > 0) { - const dataTransfer = new DataTransfer(); - dataTransfer.items.add(pdfFiles[0]); - handleFileSelect(dataTransfer.files); - } - } - }); - - fileInput.addEventListener('click', function () { - fileInput.value = ''; - }); - } - - if (processBtn) { - processBtn.addEventListener('click', fixPageSize); - } + if (processBtn) { + processBtn.addEventListener('click', fixPageSize); + } }); diff --git a/src/js/logic/font-to-outline-page.ts b/src/js/logic/font-to-outline-page.ts index 83bbb29..639d192 100644 --- a/src/js/logic/font-to-outline-page.ts +++ b/src/js/logic/font-to-outline-page.ts @@ -1,6 +1,8 @@ import { showAlert } from '../ui.js'; import { downloadFile, formatBytes } from '../utils/helpers.js'; import { convertFileToOutlines } from '../utils/ghostscript-loader.js'; +import { isGhostscriptAvailable } from '../utils/ghostscript-dynamic-loader.js'; +import { showWasmRequiredDialog } from '../utils/wasm-provider.js'; import { icons, createIcons } from 'lucide'; import JSZip from 'jszip'; @@ -98,6 +100,12 @@ async function processFiles() { return; } + // Check if Ghostscript is configured + if (!isGhostscriptAvailable()) { + showWasmRequiredDialog('ghostscript'); + return; + } + const loaderModal = document.getElementById('loader-modal'); const loaderText = document.getElementById('loader-text'); diff --git a/src/js/logic/form-creator-extraction.ts b/src/js/logic/form-creator-extraction.ts new file mode 100644 index 0000000..ad90f85 --- /dev/null +++ b/src/js/logic/form-creator-extraction.ts @@ -0,0 +1,405 @@ +import { + PDFArray, + PDFButton, + PDFCheckBox, + PDFDict, + PDFDocument, + PDFDropdown, + PDFName, + PDFOptionList, + PDFRadioGroup, + PDFRef, + PDFSignature, + PDFString, + PDFTextField, + PDFWidgetAnnotation, + TextAlignment, +} from 'pdf-lib'; +import type { + ExtractExistingFieldsOptions, + ExtractExistingFieldsResult, + ExtractionViewportMetrics, + FormField, +} from '@/types'; + +type SupportedPdfField = + | PDFTextField + | PDFCheckBox + | PDFRadioGroup + | PDFDropdown + | PDFOptionList + | PDFButton + | PDFSignature; + +type SupportedFieldType = FormField['type']; + +function isSupportedPdfField(field: unknown): field is SupportedPdfField { + return ( + field instanceof PDFTextField || + field instanceof PDFCheckBox || + field instanceof PDFRadioGroup || + field instanceof PDFDropdown || + field instanceof PDFOptionList || + field instanceof PDFButton || + field instanceof PDFSignature + ); +} + +function getSupportedFieldType(field: SupportedPdfField): SupportedFieldType { + if (field instanceof PDFTextField) return 'text'; + if (field instanceof PDFCheckBox) return 'checkbox'; + if (field instanceof PDFRadioGroup) return 'radio'; + if (field instanceof PDFDropdown) return 'dropdown'; + if (field instanceof PDFOptionList) return 'optionlist'; + if (field instanceof PDFButton) return 'button'; + return 'signature'; +} + +function getTooltip(widget: PDFWidgetAnnotation): string { + const tooltip = widget.dict.get(PDFName.of('TU')); + if (!(tooltip instanceof PDFString)) { + return ''; + } + + try { + return tooltip.decodeText(); + } catch (error) { + console.warn( + 'Failed to decode form field tooltip during extraction:', + error + ); + return ''; + } +} + +function getPageAnnotationRefs( + pdfDoc: PDFDocument, + pageIndex: number +): PDFRef[] { + const page = pdfDoc.getPages()[pageIndex]; + const annots = page.node.get(PDFName.of('Annots')); + if (!annots) return []; + + if (annots instanceof PDFArray) { + return annots + .asArray() + .map((entry) => { + if (entry instanceof PDFRef) { + return entry; + } + + return pdfDoc.context.getObjectRef(entry) ?? null; + }) + .filter((entry): entry is PDFRef => entry instanceof PDFRef); + } + + const annotsArray = pdfDoc.context.lookupMaybe(annots, PDFArray); + if (!annotsArray) return []; + + return annotsArray + .asArray() + .map((entry) => { + if (entry instanceof PDFRef) { + return entry; + } + + return pdfDoc.context.getObjectRef(entry) ?? null; + }) + .filter((entry): entry is PDFRef => entry instanceof PDFRef); +} + +export function resolveWidgetPageIndex( + pdfDoc: PDFDocument, + widget: PDFWidgetAnnotation +): number | null { + const pdfPages = pdfDoc.getPages(); + const pageRef = widget.P(); + + if (pageRef instanceof PDFRef) { + for (let pageIndex = 0; pageIndex < pdfPages.length; pageIndex += 1) { + if (pdfPages[pageIndex].ref === pageRef) { + return pageIndex; + } + } + } + + for (let pageIndex = 0; pageIndex < pdfPages.length; pageIndex += 1) { + const annotRefs = getPageAnnotationRefs(pdfDoc, pageIndex); + for (const annotRef of annotRefs) { + const annotDict = pdfDoc.context.lookupMaybe(annotRef, PDFDict); + if (annotDict === widget.dict) { + return pageIndex; + } + } + } + + return null; +} + +function buildBaseField( + pdfField: SupportedPdfField, + fieldType: SupportedFieldType, + widget: PDFWidgetAnnotation, + pageIndex: number, + id: string, + metrics: ExtractionViewportMetrics, + pdfDoc: PDFDocument +): FormField { + const rect = widget.getRectangle(); + const page = pdfDoc.getPages()[pageIndex]; + const { height: pageHeight } = page.getSize(); + + const canvasX = rect.x * metrics.pdfViewerScale + metrics.pdfViewerOffset.x; + const canvasY = + (pageHeight - rect.y - rect.height) * metrics.pdfViewerScale + + metrics.pdfViewerOffset.y; + const canvasWidth = rect.width * metrics.pdfViewerScale; + const canvasHeight = rect.height * metrics.pdfViewerScale; + + return { + id, + type: fieldType, + x: canvasX, + y: canvasY, + width: canvasWidth, + height: canvasHeight, + name: pdfField.getName(), + defaultValue: '', + fontSize: 12, + alignment: 'left', + textColor: '#000000', + required: pdfField.isRequired(), + readOnly: pdfField.isReadOnly(), + tooltip: getTooltip(widget), + combCells: 0, + maxLength: 0, + pageIndex, + borderColor: '#000000', + hideBorder: false, + }; +} + +function applyTextFieldDetails(field: FormField, pdfField: PDFTextField): void { + try { + field.defaultValue = pdfField.getText() || ''; + } catch (error) { + console.warn( + `Failed to read default text for field "${pdfField.getName()}" during extraction:`, + error + ); + } + + try { + field.multiline = pdfField.isMultiline(); + } catch (error) { + console.warn( + `Failed to read multiline setting for field "${pdfField.getName()}" during extraction:`, + error + ); + } + + try { + const maxLength = pdfField.getMaxLength(); + if (maxLength !== undefined) { + if (pdfField.isCombed()) { + field.combCells = maxLength; + } else { + field.maxLength = maxLength; + } + } + } catch (error) { + console.warn( + `Failed to read max length for field "${pdfField.getName()}" during extraction:`, + error + ); + } + + try { + const alignment = pdfField.getAlignment(); + if (alignment === TextAlignment.Center) field.alignment = 'center'; + else if (alignment === TextAlignment.Right) field.alignment = 'right'; + else field.alignment = 'left'; + } catch (error) { + console.warn( + `Failed to read alignment for field "${pdfField.getName()}" during extraction:`, + error + ); + field.alignment = 'left'; + } +} + +function applyChoiceFieldDetails( + field: FormField, + pdfField: PDFDropdown | PDFOptionList +): void { + try { + field.options = pdfField.getOptions(); + } catch (error) { + console.warn( + `Failed to read options for field "${pdfField.getName()}" during extraction:`, + error + ); + } + + try { + const selected = pdfField.getSelected(); + if (selected.length > 0) { + field.defaultValue = selected[0]; + } + } catch (error) { + console.warn( + `Failed to read selected option for field "${pdfField.getName()}" during extraction:`, + error + ); + } +} + +function buildStandardField( + pdfDoc: PDFDocument, + pdfField: Exclude, + widget: PDFWidgetAnnotation, + pageIndex: number, + id: string, + metrics: ExtractionViewportMetrics +): FormField { + const field = buildBaseField( + pdfField, + getSupportedFieldType(pdfField), + widget, + pageIndex, + id, + metrics, + pdfDoc + ); + + if (pdfField instanceof PDFTextField) { + applyTextFieldDetails(field, pdfField); + } else if (pdfField instanceof PDFCheckBox) { + try { + field.checked = pdfField.isChecked(); + } catch (error) { + console.warn( + `Failed to read checkbox state for field "${pdfField.getName()}" during extraction:`, + error + ); + field.checked = false; + } + field.exportValue = 'Yes'; + } else if (pdfField instanceof PDFDropdown) { + applyChoiceFieldDetails(field, pdfField); + } else if (pdfField instanceof PDFOptionList) { + applyChoiceFieldDetails(field, pdfField); + } else if (pdfField instanceof PDFButton) { + field.label = 'Button'; + field.action = 'none'; + } + + return field; +} + +function buildRadioField( + pdfDoc: PDFDocument, + pdfField: PDFRadioGroup, + widget: PDFWidgetAnnotation, + pageIndex: number, + id: string, + metrics: ExtractionViewportMetrics, + exportValue: string +): FormField { + const field = buildBaseField( + pdfField, + 'radio', + widget, + pageIndex, + id, + metrics, + pdfDoc + ); + + field.checked = false; + field.exportValue = exportValue; + field.groupName = pdfField.getName(); + + return field; +} + +export function extractExistingFields( + options: ExtractExistingFieldsOptions +): ExtractExistingFieldsResult { + const { pdfDoc, fieldCounterStart, metrics } = options; + const form = pdfDoc.getForm(); + const extractedFieldNames = new Set(); + const extractedFields: FormField[] = []; + let fieldCounter = fieldCounterStart; + + for (const rawField of form.getFields()) { + if (!isSupportedPdfField(rawField)) continue; + + const fieldName = rawField.getName(); + const widgets = rawField.acroField.getWidgets(); + if (widgets.length === 0) continue; + + if (rawField instanceof PDFRadioGroup) { + const options = rawField.getOptions(); + + for ( + let widgetIndex = 0; + widgetIndex < widgets.length; + widgetIndex += 1 + ) { + const widget = widgets[widgetIndex]; + const pageIndex = resolveWidgetPageIndex(pdfDoc, widget); + if (pageIndex === null) { + console.warn( + `Could not resolve page for existing radio widget "${fieldName}", skipping extraction` + ); + continue; + } + + fieldCounter += 1; + extractedFields.push( + buildRadioField( + pdfDoc, + rawField, + widget, + pageIndex, + `field_${fieldCounter}`, + metrics, + options[widgetIndex] || 'Yes' + ) + ); + } + + extractedFieldNames.add(fieldName); + continue; + } + + const widget = widgets[0]; + const pageIndex = resolveWidgetPageIndex(pdfDoc, widget); + if (pageIndex === null) { + console.warn( + `Could not resolve page for existing field "${fieldName}", skipping extraction` + ); + continue; + } + + fieldCounter += 1; + extractedFields.push( + buildStandardField( + pdfDoc, + rawField, + widget, + pageIndex, + `field_${fieldCounter}`, + metrics + ) + ); + extractedFieldNames.add(fieldName); + } + + return { + fields: extractedFields, + extractedFieldNames, + nextFieldCounter: fieldCounter, + }; +} diff --git a/src/js/logic/form-creator.ts b/src/js/logic/form-creator.ts index 98854b7..15982bf 100644 --- a/src/js/logic/form-creator.ts +++ b/src/js/logic/form-creator.ts @@ -6,15 +6,33 @@ import { PDFName, PDFString, PageSizes, - PDFBool, PDFDict, PDFArray, PDFRadioGroup, } from 'pdf-lib'; + +type FormFieldAction = NonNullable; +type FormFieldVisibilityAction = NonNullable; +type LucideWindow = Window & { + lucide?: { + createIcons(): void; + }; +}; +type PdfViewerApplicationLike = { + pdfViewer?: { + pagesCount: number; + }; +}; +type PdfViewerWindow = Window & { + PDFViewerApplication?: PdfViewerApplicationLike; +}; + import { initializeGlobalShortcuts } from '../utils/shortcuts-init.js'; import { downloadFile, hexToRgb, getPDFDocument } from '../utils/helpers.js'; import { createIcons, icons } from 'lucide'; import * as pdfjsLib from 'pdfjs-dist'; +import type { PDFDocumentProxy } from 'pdfjs-dist'; +import * as bwipjs from 'bwip-js/browser'; import 'pdfjs-dist/web/pdf_viewer.css'; // Initialize PDF.js worker @@ -23,7 +41,13 @@ pdfjsLib.GlobalWorkerOptions.workerSrc = new URL( import.meta.url ).toString(); -import { FormField, PageData } from '../types/index.js'; +import { + ExtractExistingFieldsResult, + FormCreatorFieldType, + FormField, + PageData, +} from '@/types'; +import { extractExistingFields as extractExistingPdfFields } from './form-creator-extraction.js'; let fields: FormField[] = []; let selectedField: FormField | null = null; @@ -33,11 +57,12 @@ const existingRadioGroups: Set = new Set(); let draggedElement: HTMLElement | null = null; let offsetX = 0; let offsetY = 0; +let pendingFieldExtraction = false; let pages: PageData[] = []; let currentPageIndex = 0; let uploadedPdfDoc: PDFDocument | null = null; -let uploadedPdfjsDoc: any = null; +let uploadedPdfjsDoc: PDFDocumentProxy | null = null; let pageSize: { width: number; height: number } = { width: 612, height: 792 }; let currentScale = 1.333; let pdfViewerOffset = { x: 0, y: 0 }; @@ -332,8 +357,9 @@ toolItems.forEach((item) => { ) { const x = touch.clientX - canvasRect.left - 75; const y = touch.clientY - canvasRect.top - 15; - const type = (item as HTMLElement).dataset.type || 'text'; - createField(type as any, x, y); + const type = ((item as HTMLElement).dataset.type || + 'text') as FormCreatorFieldType; + createField(type, x, y); } }); }); @@ -352,8 +378,9 @@ canvas.addEventListener('drop', (e) => { const rect = canvas.getBoundingClientRect(); const x = e.clientX - rect.left - 75; const y = e.clientY - rect.top - 15; - const type = e.dataTransfer?.getData('text/plain') || 'text'; - createField(type as any, x, y); + const type = (e.dataTransfer?.getData('text/plain') || + 'text') as FormCreatorFieldType; + createField(type, x, y); }); canvas.addEventListener('click', (e) => { @@ -361,7 +388,7 @@ canvas.addEventListener('click', (e) => { const rect = canvas.getBoundingClientRect(); const x = e.clientX - rect.left - 75; const y = e.clientY - rect.top - 15; - createField(selectedToolType as any, x, y); + createField(selectedToolType as FormCreatorFieldType, x, y); toolItems.forEach((item) => item.classList.remove('ring-2', 'ring-indigo-400', 'bg-indigo-600') @@ -384,8 +411,18 @@ function createField(type: FormField['type'], x: number, y: number): void { type: type, x: Math.max(0, Math.min(x, 816 - 150)), y: Math.max(0, Math.min(y, 1056 - 30)), - width: type === 'checkbox' || type === 'radio' ? 30 : 150, - height: type === 'checkbox' || type === 'radio' ? 30 : 30, + width: + type === 'checkbox' || type === 'radio' + ? 30 + : type === 'barcode' + ? 150 + : 150, + height: + type === 'checkbox' || type === 'radio' + ? 30 + : type === 'barcode' + ? 150 + : 30, name: `${type.charAt(0).toUpperCase() + type.slice(1)}_${fieldCounter}`, defaultValue: '', fontSize: 12, @@ -417,6 +454,8 @@ function createField(type: FormField['type'], x: number, y: number): void { multiline: type === 'text' ? false : undefined, borderColor: '#000000', hideBorder: false, + barcodeFormat: type === 'barcode' ? 'qrcode' : undefined, + barcodeValue: type === 'barcode' ? 'https://example.com' : undefined, }; fields.push(field); @@ -564,17 +603,47 @@ function renderField(field: FormField): void { 'w-full h-full flex items-center justify-center bg-gray-50 text-gray-400'; contentEl.innerHTML = '
Sign Here
'; - setTimeout(() => (window as any).lucide?.createIcons(), 0); + setTimeout(() => (window as LucideWindow).lucide?.createIcons(), 0); } else if (field.type === 'date') { contentEl.className = 'w-full h-full flex items-center justify-center bg-white text-gray-600 border border-gray-300'; contentEl.innerHTML = `
${field.dateFormat || 'mm/dd/yyyy'}
`; - setTimeout(() => (window as any).lucide?.createIcons(), 0); + setTimeout(() => (window as LucideWindow).lucide?.createIcons(), 0); } else if (field.type === 'image') { contentEl.className = 'w-full h-full flex items-center justify-center bg-gray-100 text-gray-500 border border-gray-300'; contentEl.innerHTML = `
${field.label || 'Click to Upload Image'}
`; - setTimeout(() => (window as any).lucide?.createIcons(), 0); + setTimeout(() => (window as LucideWindow).lucide?.createIcons(), 0); + } else if (field.type === 'barcode') { + contentEl.className = + 'w-full h-full flex items-center justify-center bg-white'; + if (field.barcodeValue) { + try { + const offscreen = document.createElement('canvas'); + bwipjs.toCanvas(offscreen, { + bcid: field.barcodeFormat || 'qrcode', + text: field.barcodeValue, + scale: 2, + includetext: + field.barcodeFormat !== 'qrcode' && + field.barcodeFormat !== 'datamatrix', + }); + const img = document.createElement('img'); + img.src = offscreen.toDataURL('image/png'); + img.className = 'max-w-full max-h-full object-contain'; + contentEl.appendChild(img); + } catch (error) { + console.warn( + `Failed to render barcode preview for field "${field.name}":`, + error + ); + contentEl.innerHTML = `
Invalid data
`; + setTimeout(() => (window as LucideWindow).lucide?.createIcons(), 0); + } + } else { + contentEl.innerHTML = `
Barcode
`; + setTimeout(() => (window as LucideWindow).lucide?.createIcons(), 0); + } } fieldContainer.appendChild(contentEl); @@ -659,6 +728,17 @@ function renderField(field: FormField): void { }; handle.className += ` ${positions[pos]}`; handle.dataset.position = pos; + const cursorMap: Record = { + nw: 'nwse-resize', + ne: 'nesw-resize', + sw: 'nesw-resize', + se: 'nwse-resize', + n: 'ns-resize', + s: 'ns-resize', + e: 'ew-resize', + w: 'ew-resize', + }; + handle.style.cursor = cursorMap[pos] || 'pointer'; handle.addEventListener('mousedown', (e) => { e.stopPropagation(); @@ -698,6 +778,50 @@ function startResize(e: MouseEvent, field: FormField, pos: string): void { e.preventDefault(); } +function applyResizeWithConstraints( + field: FormField, + pos: string, + dx: number, + dy: number +): void { + const isSquareField = field.type === 'checkbox' || field.type === 'radio'; + const minWidth = isSquareField ? 12 : 50; + const minHeight = isSquareField ? 12 : 20; + + if (pos.includes('e')) { + field.width = Math.max(minWidth, startWidth + dx); + } + if (pos.includes('w')) { + const newWidth = Math.max(minWidth, startWidth - dx); + const widthDiff = startWidth - newWidth; + field.width = newWidth; + field.x = startLeft + widthDiff; + } + if (pos.includes('s')) { + field.height = Math.max(minHeight, startHeight + dy); + } + if (pos.includes('n')) { + const newHeight = Math.max(minHeight, startHeight - dy); + const heightDiff = startHeight - newHeight; + field.height = newHeight; + field.y = startTop + heightDiff; + } + + if (isSquareField) { + const size = Math.max(minWidth, Math.min(field.width, field.height)); + + if (pos.includes('w')) { + field.x = startLeft + (startWidth - size); + } + if (pos.includes('n')) { + field.y = startTop + (startHeight - size); + } + + field.width = size; + field.height = size; + } +} + // Mouse move for dragging and resizing document.addEventListener('mousemove', (e) => { if (draggedElement && !resizing) { @@ -724,24 +848,7 @@ document.addEventListener('mousemove', (e) => { const dy = e.clientY - startY; const fieldWrapper = document.getElementById(resizeField.id); - if (resizePos!.includes('e')) { - resizeField.width = Math.max(50, startWidth + dx); - } - if (resizePos!.includes('w')) { - const newWidth = Math.max(50, startWidth - dx); - const widthDiff = startWidth - newWidth; - resizeField.width = newWidth; - resizeField.x = startLeft + widthDiff; - } - if (resizePos!.includes('s')) { - resizeField.height = Math.max(20, startHeight + dy); - } - if (resizePos!.includes('n')) { - const newHeight = Math.max(20, startHeight - dy); - const heightDiff = startHeight - newHeight; - resizeField.height = newHeight; - resizeField.y = startTop + heightDiff; - } + applyResizeWithConstraints(resizeField, resizePos!, dx, dy); if (fieldWrapper) { const container = fieldWrapper.querySelector( @@ -781,24 +888,7 @@ document.addEventListener( const dy = touch.clientY - startY; const fieldWrapper = document.getElementById(resizeField.id); - if (resizePos!.includes('e')) { - resizeField.width = Math.max(50, startWidth + dx); - } - if (resizePos!.includes('w')) { - const newWidth = Math.max(50, startWidth - dx); - const widthDiff = startWidth - newWidth; - resizeField.width = newWidth; - resizeField.x = startLeft + widthDiff; - } - if (resizePos!.includes('s')) { - resizeField.height = Math.max(20, startHeight + dy); - } - if (resizePos!.includes('n')) { - const newHeight = Math.max(20, startHeight - dy); - const heightDiff = startHeight - newHeight; - resizeField.height = newHeight; - resizeField.y = startTop + heightDiff; - } + applyResizeWithConstraints(resizeField, resizePos!, dx, dy); if (fieldWrapper) { const container = fieldWrapper.querySelector( @@ -931,7 +1021,7 @@ function showProperties(field: FormField): void {
- +
@@ -1114,6 +1204,26 @@ function showProperties(field: FormField): void { Clicking this field in the PDF will open a file picker to upload an image.
`; + } else if (field.type === 'barcode') { + specificProps = ` +
+ + +
+
+ + +
+
+ `; } propertiesPanel.innerHTML = ` @@ -1169,7 +1279,7 @@ function showProperties(field: FormField): void {
- +
@@ -1614,7 +1724,9 @@ function showProperties(field: FormField): void { ) as HTMLDivElement; propAction.addEventListener('change', (e) => { - field.action = (e.target as HTMLSelectElement).value as any; + const actionValue = (e.target as HTMLSelectElement) + .value as FormFieldAction; + field.action = actionValue; // Show/hide containers propUrlContainer.classList.add('hidden'); @@ -1660,7 +1772,8 @@ function showProperties(field: FormField): void { ) as HTMLSelectElement; if (propVisibilityAction) { propVisibilityAction.addEventListener('change', (e) => { - field.visibilityAction = (e.target as HTMLSelectElement).value as any; + field.visibilityAction = (e.target as HTMLSelectElement) + .value as FormFieldVisibilityAction; }); } } else if (field.type === 'signature') { @@ -1770,7 +1883,7 @@ function showProperties(field: FormField): void { textSpan.textContent = field.dateFormat; } } - setTimeout(() => (window as any).lucide?.createIcons(), 0); + setTimeout(() => (window as LucideWindow).lucide?.createIcons(), 0); }); } @@ -1795,6 +1908,56 @@ function showProperties(field: FormField): void { field.label = (e.target as HTMLInputElement).value; renderField(field); }); + } else if (field.type === 'barcode') { + const propBarcodeFormat = document.getElementById( + 'propBarcodeFormat' + ) as HTMLSelectElement; + const propBarcodeValue = document.getElementById( + 'propBarcodeValue' + ) as HTMLInputElement; + + const barcodeSampleValues: Record = { + qrcode: 'https://example.com', + code128: 'ABC-123', + code39: 'ABC123', + ean13: '590123412345', + upca: '01234567890', + datamatrix: 'https://example.com', + pdf417: 'https://example.com', + }; + + const barcodeFormatHints: Record = { + qrcode: 'Any text, URL, or data', + code128: 'ASCII characters (letters, numbers, symbols)', + code39: 'Uppercase A-Z, digits 0-9, and - . $ / + % SPACE', + ean13: 'Exactly 12 or 13 digits', + upca: 'Exactly 11 or 12 digits', + datamatrix: 'Any text, URL, or data', + pdf417: 'Any text, URL, or data', + }; + + const hintEl = document.getElementById('barcodeFormatHint'); + if (hintEl) + hintEl.textContent = + barcodeFormatHints[field.barcodeFormat || 'qrcode'] || ''; + + if (propBarcodeFormat) { + propBarcodeFormat.addEventListener('change', (e) => { + const newFormat = (e.target as HTMLSelectElement).value; + field.barcodeFormat = newFormat; + field.barcodeValue = barcodeSampleValues[newFormat] || 'hello'; + if (propBarcodeValue) propBarcodeValue.value = field.barcodeValue; + if (hintEl) hintEl.textContent = barcodeFormatHints[newFormat] || ''; + renderField(field); + }); + } + + if (propBarcodeValue) { + propBarcodeValue.addEventListener('input', (e) => { + field.barcodeValue = (e.target as HTMLInputElement).value; + renderField(field); + }); + } } } @@ -1911,6 +2074,19 @@ downloadBtn.addEventListener('click', async () => { const form = pdfDoc.getForm(); + if (extractedFieldNames.size > 0) { + for (const fieldName of extractedFieldNames) { + try { + const existingField = form.getFieldMaybe(fieldName); + if (existingField) { + form.removeField(existingField); + } + } catch (e) { + console.warn(`Failed to remove existing field "${fieldName}":`, e); + } + } + } + const helveticaFont = await pdfDoc.embedFont(StandardFonts.Helvetica); // Set document metadata for accessibility @@ -1918,7 +2094,10 @@ downloadBtn.addEventListener('click', async () => { pdfDoc.setAuthor('BentoPDF'); pdfDoc.setLanguage('en-US'); - const radioGroups = new Map(); // Track created radio groups + const radioGroups = new Map< + string, + ReturnType + >(); for (const field of fields) { const pageData = pages[field.pageIndex]; @@ -2037,7 +2216,7 @@ downloadBtn.addEventListener('click', async () => { } const borderRgb = hexToRgb(field.borderColor || '#000000'); - radioGroup.addOptionToPage(field.exportValue || 'Yes', pdfPage as any, { + radioGroup.addOptionToPage(field.exportValue || 'Yes', pdfPage, { x: x, y: y, width: width, @@ -2050,7 +2229,7 @@ downloadBtn.addEventListener('click', async () => { if (field.required) radioGroup.enableRequired(); if (field.readOnly) radioGroup.enableReadOnly(); if (field.tooltip) { - radioGroup.acroField.getWidgets().forEach((widget: any) => { + radioGroup.acroField.getWidgets().forEach((widget) => { widget.dict.set(PDFName.of('TU'), PDFString.of(field.tooltip)); }); } @@ -2134,7 +2313,7 @@ downloadBtn.addEventListener('click', async () => { const widgets = button.acroField.getWidgets(); widgets.forEach((widget) => { - let actionDict: any; + let actionDict: PDFDict | PDFArray | undefined; if (field.action === 'reset') { actionDict = pdfDoc.context.obj({ @@ -2175,7 +2354,7 @@ downloadBtn.addEventListener('click', async () => { }); } else if (field.action === 'showHide' && field.targetFieldName) { const target = field.targetFieldName; - let script = ''; + let script: string; if (field.visibilityAction === 'show') { script = `var f = this.getField("${target}"); if(f) f.display = display.visible;`; @@ -2332,6 +2511,32 @@ downloadBtn.addEventListener('click', async () => { if (field.tooltip) { widgetDict.set(PDFName.of('TU'), PDFString.of(field.tooltip)); } + } else if (field.type === 'barcode') { + if (field.barcodeValue) { + try { + const offscreen = document.createElement('canvas'); + bwipjs.toCanvas(offscreen, { + bcid: field.barcodeFormat || 'qrcode', + text: field.barcodeValue, + scale: 3, + includetext: + field.barcodeFormat !== 'qrcode' && + field.barcodeFormat !== 'datamatrix', + }); + const dataUrl = offscreen.toDataURL('image/png'); + const base64 = dataUrl.split(',')[1]; + const pngBytes = Uint8Array.from(atob(base64), (c) => + c.charCodeAt(0) + ); + const pngImage = await pdfDoc.embedPng(pngBytes); + pdfPage.drawImage(pngImage, { x, y, width, height }); + } catch (e) { + console.warn( + `Failed to generate barcode for field "${field.name}":`, + e + ); + } + } } } @@ -2429,6 +2634,8 @@ function resetToInitial(): void { currentPageIndex = 0; uploadedPdfDoc = null; selectedField = null; + extractedFieldNames.clear(); + pendingFieldExtraction = false; canvas.innerHTML = ''; @@ -2504,7 +2711,7 @@ async function renderCanvas(): Promise { iframe.onload = () => { try { - const viewerWindow = iframe.contentWindow as any; + const viewerWindow = iframe.contentWindow as PdfViewerWindow | null; if (viewerWindow && viewerWindow.PDFViewerApplication) { const app = viewerWindow.PDFViewerApplication; @@ -2563,7 +2770,7 @@ async function renderCanvas(): Promise { clearInterval(checkRender); const pageContainer = - viewerWindow.document.querySelector('.page'); + viewerWindow.document.querySelector('.page'); if (pageContainer) { const initialRect = pageContainer.getBoundingClientRect(); @@ -2611,6 +2818,32 @@ async function renderCanvas(): Promise { height: currentPage.height, }, }); + + if (pendingFieldExtraction && uploadedPdfDoc) { + pendingFieldExtraction = false; + extractExistingFields(uploadedPdfDoc); + extractedFieldNames.forEach((name) => + existingFieldNames.delete(name) + ); + + const form = uploadedPdfDoc.getForm(); + for (const name of extractedFieldNames) { + try { + const existingField = form.getFieldMaybe(name); + if (existingField) { + form.removeField(existingField); + } + } catch (error) { + console.warn( + `Failed to remove extracted field "${name}" after import:`, + error + ); + } + } + + renderCanvas(); + updateFieldCount(); + } }, 50); } } @@ -2701,6 +2934,35 @@ confirmBlankBtn.addEventListener('click', () => { setTimeout(() => createIcons({ icons }), 100); }); +const extractedFieldNames: Set = new Set(); + +function extractExistingFields(pdfDoc: PDFDocument): void { + try { + const extractionResult: ExtractExistingFieldsResult = + extractExistingPdfFields({ + pdfDoc, + fieldCounterStart: fieldCounter, + metrics: { + pdfViewerOffset, + pdfViewerScale, + }, + }); + + fields.push(...extractionResult.fields); + fieldCounter = extractionResult.nextFieldCounter; + + extractionResult.extractedFieldNames.forEach((name) => { + extractedFieldNames.add(name); + }); + + console.log( + `Extracted ${extractionResult.extractedFieldNames.size} existing fields for editing` + ); + } catch (error) { + console.warn('Error extracting existing fields:', error); + } +} + async function handlePdfUpload(file: File) { try { const arrayBuffer = await file.arrayBuffer(); @@ -2759,6 +3021,9 @@ async function handlePdfUpload(file: File) { } currentPageIndex = 0; + + pendingFieldExtraction = true; + renderCanvas(); updatePageNavigation(); diff --git a/src/js/logic/image-to-pdf-page.ts b/src/js/logic/image-to-pdf-page.ts index 3051aca..3cef7e4 100644 --- a/src/js/logic/image-to-pdf-page.ts +++ b/src/js/logic/image-to-pdf-page.ts @@ -1,275 +1,299 @@ import { createIcons, icons } from 'lucide'; import { showAlert, showLoader, hideLoader } from '../ui.js'; import { downloadFile, formatBytes } from '../utils/helpers.js'; -import { PyMuPDF } from '@bentopdf/pymupdf-wasm'; -import { getWasmBaseUrl } from '../config/wasm-cdn-config.js'; +import { isWasmAvailable, getWasmBaseUrl } from '../config/wasm-cdn-config.js'; +import { showWasmRequiredDialog } from '../utils/wasm-provider.js'; +import { loadPyMuPDF, isPyMuPDFAvailable } from '../utils/pymupdf-loader.js'; import heic2any from 'heic2any'; +import { + getSelectedQuality, + compressImageFile, +} from '../utils/image-compress.js'; -const SUPPORTED_FORMATS = '.jpg,.jpeg,.png,.bmp,.gif,.tiff,.tif,.pnm,.pgm,.pbm,.ppm,.pam,.jxr,.jpx,.jp2,.psd,.svg,.heic,.heif,.webp'; -const SUPPORTED_FORMATS_DISPLAY = 'JPG, PNG, BMP, GIF, TIFF, PNM, PGM, PBM, PPM, PAM, JXR, JPX, JP2, PSD, SVG, HEIC, WebP'; +const SUPPORTED_FORMATS = + '.jpg,.jpeg,.png,.bmp,.gif,.tiff,.tif,.pnm,.pgm,.pbm,.ppm,.pam,.jxr,.jpx,.jp2,.psd,.svg,.heic,.heif,.webp'; +const SUPPORTED_FORMATS_DISPLAY = + 'JPG, PNG, BMP, GIF, TIFF, PNM, PGM, PBM, PPM, PAM, JXR, JPX, JP2, PSD, SVG, HEIC, WebP'; let files: File[] = []; -let pymupdf: PyMuPDF | null = null; +let pymupdf: any = null; if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', initializePage); + document.addEventListener('DOMContentLoaded', initializePage); } else { - initializePage(); + initializePage(); } function initializePage() { - createIcons({ icons }); + createIcons({ icons }); - const fileInput = document.getElementById('file-input') as HTMLInputElement; - const dropZone = document.getElementById('drop-zone'); - const addMoreBtn = document.getElementById('add-more-btn'); - const clearFilesBtn = document.getElementById('clear-files-btn'); - const processBtn = document.getElementById('process-btn'); - const formatDisplay = document.getElementById('supported-formats'); + const fileInput = document.getElementById('file-input') as HTMLInputElement; + const dropZone = document.getElementById('drop-zone'); + const addMoreBtn = document.getElementById('add-more-btn'); + const clearFilesBtn = document.getElementById('clear-files-btn'); + const processBtn = document.getElementById('process-btn'); + const formatDisplay = document.getElementById('supported-formats'); - if (formatDisplay) { - formatDisplay.textContent = SUPPORTED_FORMATS_DISPLAY; - } + if (formatDisplay) { + formatDisplay.textContent = SUPPORTED_FORMATS_DISPLAY; + } - if (fileInput) { - fileInput.accept = SUPPORTED_FORMATS; - fileInput.addEventListener('change', handleFileUpload); - } + if (fileInput) { + fileInput.accept = SUPPORTED_FORMATS; + fileInput.addEventListener('change', handleFileUpload); + } - 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) { - handleFiles(droppedFiles); - } - }); - - fileInput?.addEventListener('click', () => { - if (fileInput) fileInput.value = ''; - }); - } - - if (addMoreBtn) { - addMoreBtn.addEventListener('click', () => { - fileInput?.click(); - }); - } - - if (clearFilesBtn) { - clearFilesBtn.addEventListener('click', () => { - files = []; - updateUI(); - }); - } - - if (processBtn) { - processBtn.addEventListener('click', convertToPdf); - } - - document.getElementById('back-to-tools')?.addEventListener('click', () => { - window.location.href = import.meta.env.BASE_URL; + 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) { + handleFiles(droppedFiles); + } + }); + + fileInput?.addEventListener('click', () => { + if (fileInput) fileInput.value = ''; + }); + } + + if (addMoreBtn) { + addMoreBtn.addEventListener('click', () => { + fileInput?.click(); + }); + } + + if (clearFilesBtn) { + clearFilesBtn.addEventListener('click', () => { + files = []; + updateUI(); + }); + } + + if (processBtn) { + processBtn.addEventListener('click', convertToPdf); + } + + document.getElementById('back-to-tools')?.addEventListener('click', () => { + window.location.href = import.meta.env.BASE_URL; + }); } function handleFileUpload(e: Event) { - const input = e.target as HTMLInputElement; - if (input.files && input.files.length > 0) { - handleFiles(input.files); - } + const input = e.target as HTMLInputElement; + if (input.files && input.files.length > 0) { + handleFiles(input.files); + } } function getFileExtension(filename: string): string { - return '.' + filename.split('.').pop()?.toLowerCase() || ''; + return '.' + filename.split('.').pop()?.toLowerCase() || ''; } function isValidImageFile(file: File): boolean { - const ext = getFileExtension(file.name); - const validExtensions = SUPPORTED_FORMATS.split(','); - return validExtensions.includes(ext) || file.type.startsWith('image/'); + const ext = getFileExtension(file.name); + const validExtensions = SUPPORTED_FORMATS.split(','); + return validExtensions.includes(ext) || file.type.startsWith('image/'); } function handleFiles(newFiles: FileList) { - const validFiles = Array.from(newFiles).filter(isValidImageFile); + const validFiles = Array.from(newFiles).filter(isValidImageFile); - if (validFiles.length < newFiles.length) { - showAlert('Invalid Files', 'Some files were skipped. Only supported image formats are allowed.'); - } + if (validFiles.length < newFiles.length) { + showAlert( + 'Invalid Files', + 'Some files were skipped. Only supported image formats are allowed.' + ); + } - if (validFiles.length > 0) { - files = [...files, ...validFiles]; - updateUI(); - } + if (validFiles.length > 0) { + files = [...files, ...validFiles]; + updateUI(); + } } const resetState = () => { - files = []; - updateUI(); + files = []; + updateUI(); }; function updateUI() { - const fileDisplayArea = document.getElementById('file-display-area'); - const fileControls = document.getElementById('file-controls'); - const optionsDiv = document.getElementById('jpg-to-pdf-options'); + const fileDisplayArea = document.getElementById('file-display-area'); + const fileControls = document.getElementById('file-controls'); + const optionsDiv = document.getElementById('jpg-to-pdf-options'); - if (!fileDisplayArea || !fileControls || !optionsDiv) return; + if (!fileDisplayArea || !fileControls || !optionsDiv) return; - fileDisplayArea.innerHTML = ''; + fileDisplayArea.innerHTML = ''; - if (files.length > 0) { - fileControls.classList.remove('hidden'); - optionsDiv.classList.remove('hidden'); + if (files.length > 0) { + fileControls.classList.remove('hidden'); + optionsDiv.classList.remove('hidden'); - files.forEach((file, index) => { - const fileDiv = document.createElement('div'); - fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm'; + files.forEach((file, index) => { + const fileDiv = document.createElement('div'); + fileDiv.className = + 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm'; - const infoContainer = document.createElement('div'); - infoContainer.className = 'flex items-center gap-2 overflow-hidden'; + const infoContainer = document.createElement('div'); + infoContainer.className = 'flex items-center gap-2 overflow-hidden'; - const nameSpan = document.createElement('span'); - nameSpan.className = 'truncate font-medium text-gray-200'; - nameSpan.textContent = file.name; + const nameSpan = document.createElement('span'); + nameSpan.className = 'truncate font-medium text-gray-200'; + nameSpan.textContent = file.name; - const sizeSpan = document.createElement('span'); - sizeSpan.className = 'flex-shrink-0 text-gray-400 text-xs'; - sizeSpan.textContent = `(${formatBytes(file.size)})`; + const sizeSpan = document.createElement('span'); + sizeSpan.className = 'flex-shrink-0 text-gray-400 text-xs'; + sizeSpan.textContent = `(${formatBytes(file.size)})`; - infoContainer.append(nameSpan, sizeSpan); + infoContainer.append(nameSpan, sizeSpan); - const removeBtn = document.createElement('button'); - removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0'; - removeBtn.innerHTML = ''; - removeBtn.onclick = () => { - files = files.filter((_, i) => i !== index); - updateUI(); - }; + const removeBtn = document.createElement('button'); + removeBtn.className = + 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0'; + removeBtn.innerHTML = ''; + removeBtn.onclick = () => { + files = files.filter((_, i) => i !== index); + updateUI(); + }; - fileDiv.append(infoContainer, removeBtn); - fileDisplayArea.appendChild(fileDiv); - }); - createIcons({ icons }); - } else { - fileControls.classList.add('hidden'); - optionsDiv.classList.add('hidden'); - } + fileDiv.append(infoContainer, removeBtn); + fileDisplayArea.appendChild(fileDiv); + }); + createIcons({ icons }); + } else { + fileControls.classList.add('hidden'); + optionsDiv.classList.add('hidden'); + } } -async function ensurePyMuPDF(): Promise { - if (!pymupdf) { - pymupdf = new PyMuPDF(getWasmBaseUrl('pymupdf')); - await pymupdf.load(); - } - return pymupdf; +async function ensurePyMuPDF(): Promise { + if (!pymupdf) { + pymupdf = await loadPyMuPDF(); + } + return pymupdf; } async function preprocessFile(file: File): Promise { - const ext = getFileExtension(file.name); + const ext = getFileExtension(file.name); - if (ext === '.heic' || ext === '.heif') { - try { - const conversionResult = await heic2any({ - blob: file, - toType: 'image/png', - quality: 0.9, - }); + if (ext === '.heic' || ext === '.heif') { + try { + const conversionResult = await heic2any({ + blob: file, + toType: 'image/png', + quality: 0.9, + }); - const blob = Array.isArray(conversionResult) ? conversionResult[0] : conversionResult; - return new File([blob], file.name.replace(/\.(heic|heif)$/i, '.png'), { type: 'image/png' }); - } catch (e) { - console.error(`Failed to convert HEIC: ${file.name}`, e); - throw new Error(`Failed to process HEIC file: ${file.name}`); - } + const blob = Array.isArray(conversionResult) + ? conversionResult[0] + : conversionResult; + return new File([blob], file.name.replace(/\.(heic|heif)$/i, '.png'), { + type: 'image/png', + }); + } catch (e) { + console.error(`Failed to convert HEIC: ${file.name}`, e); + throw new Error(`Failed to process HEIC file: ${file.name}`); } + } - if (ext === '.webp') { - try { - return await new Promise((resolve, reject) => { - const img = new Image(); - const url = URL.createObjectURL(file); + if (ext === '.webp') { + try { + return await new Promise((resolve, reject) => { + const img = new Image(); + const url = URL.createObjectURL(file); - img.onload = () => { - const canvas = document.createElement('canvas'); - canvas.width = img.width; - canvas.height = img.height; - const ctx = canvas.getContext('2d'); - if (!ctx) { - URL.revokeObjectURL(url); - reject(new Error('Canvas context failed')); - return; - } - ctx.drawImage(img, 0, 0); - canvas.toBlob((blob) => { - URL.revokeObjectURL(url); - if (blob) { - resolve(new File([blob], file.name.replace(/\.webp$/i, '.png'), { type: 'image/png' })); - } else { - reject(new Error('Canvas toBlob failed')); - } - }, 'image/png'); - }; + img.onload = () => { + const canvas = document.createElement('canvas'); + canvas.width = img.width; + canvas.height = img.height; + const ctx = canvas.getContext('2d'); + if (!ctx) { + URL.revokeObjectURL(url); + reject(new Error('Canvas context failed')); + return; + } + ctx.drawImage(img, 0, 0); + canvas.toBlob((blob) => { + URL.revokeObjectURL(url); + if (blob) { + resolve( + new File([blob], file.name.replace(/\.webp$/i, '.png'), { + type: 'image/png', + }) + ); + } else { + reject(new Error('Canvas toBlob failed')); + } + }, 'image/png'); + }; - img.onerror = () => { - URL.revokeObjectURL(url); - reject(new Error('Failed to load WebP image')); - }; + img.onerror = () => { + URL.revokeObjectURL(url); + reject(new Error('Failed to load WebP image')); + }; - img.src = url; - }); - } catch (e) { - console.error(`Failed to convert WebP: ${file.name}`, e); - throw new Error(`Failed to process WebP file: ${file.name}`); - } + img.src = url; + }); + } catch (e) { + console.error(`Failed to convert WebP: ${file.name}`, e); + throw new Error(`Failed to process WebP file: ${file.name}`); } + } - return file; + return file; } async function convertToPdf() { - if (files.length === 0) { - showAlert('No Files', 'Please select at least one image file.'); - return; + if (files.length === 0) { + showAlert('No Files', 'Please select at least one image file.'); + return; + } + + showLoader('Processing images...'); + + try { + const quality = getSelectedQuality(); + const processedFiles: File[] = []; + for (const file of files) { + try { + const processed = await preprocessFile(file); + const compressed = await compressImageFile(processed, quality); + processedFiles.push(compressed); + } catch (error: any) { + console.warn(error); + throw error; + } } - showLoader('Processing images...'); + showLoader('Loading engine...'); + const mupdf = await ensurePyMuPDF(); - try { - const processedFiles: File[] = []; - for (const file of files) { - try { - const processed = await preprocessFile(file); - processedFiles.push(processed); - } catch (error: any) { - console.warn(error); - throw error; - } - } + showLoader('Converting images to PDF...'); + const pdfBlob = await mupdf.imagesToPdf(processedFiles); - showLoader('Loading engine...'); - const mupdf = await ensurePyMuPDF(); + downloadFile(pdfBlob, 'images_to_pdf.pdf'); - showLoader('Converting images to PDF...'); - const pdfBlob = await mupdf.imagesToPdf(processedFiles); - - downloadFile(pdfBlob, 'images_to_pdf.pdf'); - - showAlert('Success', 'PDF created successfully!', 'success', () => { - resetState(); - }); - } catch (e: any) { - console.error('[ImageToPDF]', e); - showAlert('Conversion Error', e.message || 'Failed to convert images to PDF.'); - } finally { - hideLoader(); - } + showAlert('Success', 'PDF created successfully!', 'success', () => { + resetState(); + }); + } catch (e: any) { + console.error('[ImageToPDF]', e); + showAlert( + 'Conversion Error', + e.message || 'Failed to convert images to PDF.' + ); + } finally { + hideLoader(); + } } diff --git a/src/js/logic/invert-colors-page.ts b/src/js/logic/invert-colors-page.ts index 4d27554..cea6048 100644 --- a/src/js/logic/invert-colors-page.ts +++ b/src/js/logic/invert-colors-page.ts @@ -2,130 +2,173 @@ import { createIcons, icons } from 'lucide'; import { showAlert, showLoader, hideLoader } from '../ui.js'; import { downloadFile, formatBytes, getPDFDocument } from '../utils/helpers.js'; import { PDFDocument as PDFLibDocument } from 'pdf-lib'; +import { applyInvertColors } from '../utils/image-effects.js'; import * as pdfjsLib from 'pdfjs-dist'; import { InvertColorsState } from '@/types'; -pdfjsLib.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).toString(); +pdfjsLib.GlobalWorkerOptions.workerSrc = new URL( + 'pdfjs-dist/build/pdf.worker.min.mjs', + import.meta.url +).toString(); const pageState: InvertColorsState = { file: null, pdfDoc: null }; -if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initializePage); } -else { initializePage(); } - -function initializePage() { - createIcons({ icons }); - const fileInput = document.getElementById('file-input') as HTMLInputElement; - const dropZone = document.getElementById('drop-zone'); - const backBtn = document.getElementById('back-to-tools'); - const processBtn = document.getElementById('process-btn'); - - if (fileInput) { - fileInput.addEventListener('change', handleFileUpload); - 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'); - if (e.dataTransfer?.files.length) handleFiles(e.dataTransfer.files); - }); - } - if (backBtn) backBtn.addEventListener('click', () => { window.location.href = import.meta.env.BASE_URL; }); - if (processBtn) processBtn.addEventListener('click', invertColors); +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initializePage); +} else { + initializePage(); } -function handleFileUpload(e: Event) { const input = e.target as HTMLInputElement; if (input.files?.length) handleFiles(input.files); } +function initializePage() { + createIcons({ icons }); + const fileInput = document.getElementById('file-input') as HTMLInputElement; + const dropZone = document.getElementById('drop-zone'); + const backBtn = document.getElementById('back-to-tools'); + const processBtn = document.getElementById('process-btn'); + + if (fileInput) { + fileInput.addEventListener('change', handleFileUpload); + 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'); + if (e.dataTransfer?.files.length) handleFiles(e.dataTransfer.files); + }); + } + if (backBtn) + backBtn.addEventListener('click', () => { + window.location.href = import.meta.env.BASE_URL; + }); + if (processBtn) processBtn.addEventListener('click', invertColors); +} + +function handleFileUpload(e: Event) { + const input = e.target as HTMLInputElement; + if (input.files?.length) handleFiles(input.files); +} async function handleFiles(files: FileList) { - const file = files[0]; - if (!file || file.type !== 'application/pdf') { showAlert('Invalid File', 'Please upload a valid PDF file.'); return; } - showLoader('Loading PDF...'); - try { - const arrayBuffer = await file.arrayBuffer(); - pageState.pdfDoc = await PDFLibDocument.load(arrayBuffer); - pageState.file = file; - updateFileDisplay(); - document.getElementById('options-panel')?.classList.remove('hidden'); - } catch (error) { console.error(error); showAlert('Error', 'Failed to load PDF file.'); } - finally { hideLoader(); } + const file = files[0]; + if (!file || file.type !== 'application/pdf') { + showAlert('Invalid File', 'Please upload a valid PDF file.'); + return; + } + showLoader('Loading PDF...'); + try { + const arrayBuffer = await file.arrayBuffer(); + pageState.pdfDoc = await PDFLibDocument.load(arrayBuffer); + pageState.file = file; + updateFileDisplay(); + document.getElementById('options-panel')?.classList.remove('hidden'); + } catch (error) { + console.error(error); + showAlert('Error', 'Failed to load PDF file.'); + } finally { + hideLoader(); + } } function updateFileDisplay() { - const fileDisplayArea = document.getElementById('file-display-area'); - if (!fileDisplayArea || !pageState.file || !pageState.pdfDoc) 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 = pageState.file.name; - const metaSpan = document.createElement('div'); - metaSpan.className = 'text-xs text-gray-400'; - metaSpan.textContent = `${formatBytes(pageState.file.size)} • ${pageState.pdfDoc.getPageCount()} pages`; - 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 }); + const fileDisplayArea = document.getElementById('file-display-area'); + if (!fileDisplayArea || !pageState.file || !pageState.pdfDoc) 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 = pageState.file.name; + const metaSpan = document.createElement('div'); + metaSpan.className = 'text-xs text-gray-400'; + metaSpan.textContent = `${formatBytes(pageState.file.size)} • ${pageState.pdfDoc.getPageCount()} pages`; + 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 }); } function resetState() { - pageState.file = null; pageState.pdfDoc = null; - const fileDisplayArea = document.getElementById('file-display-area'); - if (fileDisplayArea) fileDisplayArea.innerHTML = ''; - document.getElementById('options-panel')?.classList.add('hidden'); - const fileInput = document.getElementById('file-input') as HTMLInputElement; - if (fileInput) fileInput.value = ''; + pageState.file = null; + pageState.pdfDoc = null; + const fileDisplayArea = document.getElementById('file-display-area'); + if (fileDisplayArea) fileDisplayArea.innerHTML = ''; + document.getElementById('options-panel')?.classList.add('hidden'); + const fileInput = document.getElementById('file-input') as HTMLInputElement; + if (fileInput) fileInput.value = ''; } async function invertColors() { - if (!pageState.pdfDoc || !pageState.file) { showAlert('Error', 'Please upload a PDF file first.'); return; } - showLoader('Inverting PDF colors...'); - try { - const newPdfDoc = await PDFLibDocument.create(); - const pdfBytes = await pageState.pdfDoc.save(); - const pdfjsDoc = await getPDFDocument({ data: pdfBytes }).promise; + if (!pageState.pdfDoc || !pageState.file) { + showAlert('Error', 'Please upload a PDF file first.'); + return; + } + showLoader('Inverting PDF colors...'); + try { + const newPdfDoc = await PDFLibDocument.create(); + const pdfBytes = await pageState.pdfDoc.save(); + const pdfjsDoc = await getPDFDocument({ data: pdfBytes }).promise; - for (let i = 1; i <= pdfjsDoc.numPages; i++) { - showLoader(`Processing page ${i} of ${pdfjsDoc.numPages}...`); - const page = await pdfjsDoc.getPage(i); - const viewport = page.getViewport({ scale: 1.5 }); - const canvas = document.createElement('canvas'); - canvas.width = viewport.width; - canvas.height = viewport.height; - const ctx = canvas.getContext('2d')!; - await page.render({ canvasContext: ctx, viewport, canvas }).promise; + for (let i = 1; i <= pdfjsDoc.numPages; i++) { + showLoader(`Processing page ${i} of ${pdfjsDoc.numPages}...`); + const page = await pdfjsDoc.getPage(i); + const viewport = page.getViewport({ scale: 1.5 }); + const canvas = document.createElement('canvas'); + canvas.width = viewport.width; + canvas.height = viewport.height; + const ctx = canvas.getContext('2d')!; + await page.render({ canvasContext: ctx, viewport, canvas }).promise; - const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); - const data = imageData.data; - for (let j = 0; j < data.length; j += 4) { - data[j] = 255 - data[j]; - data[j + 1] = 255 - data[j + 1]; - data[j + 2] = 255 - data[j + 2]; - } - ctx.putImageData(imageData, 0, 0); + const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); + applyInvertColors(imageData); + ctx.putImageData(imageData, 0, 0); - const pngImageBytes = await new Promise((resolve) => - canvas.toBlob((blob) => { - const reader = new FileReader(); - reader.onload = () => resolve(new Uint8Array(reader.result as ArrayBuffer)); - reader.readAsArrayBuffer(blob!); - }, 'image/png') - ); + const pngImageBytes = await new Promise((resolve) => + canvas.toBlob((blob) => { + const reader = new FileReader(); + reader.onload = () => + resolve(new Uint8Array(reader.result as ArrayBuffer)); + reader.readAsArrayBuffer(blob!); + }, 'image/png') + ); - const image = await newPdfDoc.embedPng(pngImageBytes); - const newPage = newPdfDoc.addPage([image.width, image.height]); - newPage.drawImage(image, { x: 0, y: 0, width: image.width, height: image.height }); - } - const newPdfBytes = await newPdfDoc.save(); - downloadFile(new Blob([new Uint8Array(newPdfBytes)], { type: 'application/pdf' }), 'inverted.pdf'); - showAlert('Success', 'Colors inverted successfully!', 'success', () => { resetState(); }); - } catch (e) { console.error(e); showAlert('Error', 'Could not invert PDF colors.'); } - finally { hideLoader(); } + const image = await newPdfDoc.embedPng(pngImageBytes); + const newPage = newPdfDoc.addPage([image.width, image.height]); + newPage.drawImage(image, { + x: 0, + y: 0, + width: image.width, + height: image.height, + }); + } + const newPdfBytes = await newPdfDoc.save(); + downloadFile( + new Blob([new Uint8Array(newPdfBytes)], { type: 'application/pdf' }), + 'inverted.pdf' + ); + showAlert('Success', 'Colors inverted successfully!', 'success', () => { + resetState(); + }); + } catch (e) { + console.error(e); + showAlert('Error', 'Could not invert PDF colors.'); + } finally { + hideLoader(); + } } diff --git a/src/js/logic/jpg-to-pdf-page.ts b/src/js/logic/jpg-to-pdf-page.ts index 40264cd..e4e37ba 100644 --- a/src/js/logic/jpg-to-pdf-page.ts +++ b/src/js/logic/jpg-to-pdf-page.ts @@ -1,195 +1,212 @@ import { createIcons, icons } from 'lucide'; import { showAlert, showLoader, hideLoader } from '../ui.js'; import { downloadFile, formatBytes } from '../utils/helpers.js'; -import { PyMuPDF } from '@bentopdf/pymupdf-wasm'; -import { getWasmBaseUrl } from '../config/wasm-cdn-config.js'; +import { loadPyMuPDF } from '../utils/pymupdf-loader.js'; +import { + getSelectedQuality, + compressImageFile, +} from '../utils/image-compress.js'; const SUPPORTED_FORMATS = '.jpg,.jpeg,.jp2,.jpx'; const SUPPORTED_MIME_TYPES = ['image/jpeg', 'image/jpg', 'image/jp2']; let files: File[] = []; -let pymupdf: PyMuPDF | null = null; +let pymupdf: any = null; if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', initializePage); + document.addEventListener('DOMContentLoaded', initializePage); } else { - initializePage(); + initializePage(); } function initializePage() { - createIcons({ icons }); + createIcons({ icons }); - const fileInput = document.getElementById('file-input') as HTMLInputElement; - const dropZone = document.getElementById('drop-zone'); - const addMoreBtn = document.getElementById('add-more-btn'); - const clearFilesBtn = document.getElementById('clear-files-btn'); - const processBtn = document.getElementById('process-btn'); + const fileInput = document.getElementById('file-input') as HTMLInputElement; + const dropZone = document.getElementById('drop-zone'); + const addMoreBtn = document.getElementById('add-more-btn'); + const clearFilesBtn = document.getElementById('clear-files-btn'); + const processBtn = document.getElementById('process-btn'); - if (fileInput) { - fileInput.addEventListener('change', handleFileUpload); - } + if (fileInput) { + fileInput.addEventListener('change', handleFileUpload); + } - 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) { - handleFiles(droppedFiles); - } - }); - - fileInput?.addEventListener('click', () => { - if (fileInput) fileInput.value = ''; - }); - } - - if (addMoreBtn) { - addMoreBtn.addEventListener('click', () => { - fileInput?.click(); - }); - } - - if (clearFilesBtn) { - clearFilesBtn.addEventListener('click', () => { - files = []; - updateUI(); - }); - } - - if (processBtn) { - processBtn.addEventListener('click', convertToPdf); - } - - document.getElementById('back-to-tools')?.addEventListener('click', () => { - window.location.href = import.meta.env.BASE_URL; + 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) { + handleFiles(droppedFiles); + } + }); + + fileInput?.addEventListener('click', () => { + if (fileInput) fileInput.value = ''; + }); + } + + if (addMoreBtn) { + addMoreBtn.addEventListener('click', () => { + fileInput?.click(); + }); + } + + if (clearFilesBtn) { + clearFilesBtn.addEventListener('click', () => { + files = []; + updateUI(); + }); + } + + if (processBtn) { + processBtn.addEventListener('click', convertToPdf); + } + + document.getElementById('back-to-tools')?.addEventListener('click', () => { + window.location.href = import.meta.env.BASE_URL; + }); } function handleFileUpload(e: Event) { - const input = e.target as HTMLInputElement; - if (input.files && input.files.length > 0) { - handleFiles(input.files); - } + const input = e.target as HTMLInputElement; + if (input.files && input.files.length > 0) { + handleFiles(input.files); + } } function getFileExtension(filename: string): string { - return '.' + (filename.split('.').pop()?.toLowerCase() || ''); + return '.' + (filename.split('.').pop()?.toLowerCase() || ''); } function isValidImageFile(file: File): boolean { - const ext = getFileExtension(file.name); - const validExtensions = SUPPORTED_FORMATS.split(','); - return validExtensions.includes(ext) || SUPPORTED_MIME_TYPES.includes(file.type); + const ext = getFileExtension(file.name); + const validExtensions = SUPPORTED_FORMATS.split(','); + return ( + validExtensions.includes(ext) || SUPPORTED_MIME_TYPES.includes(file.type) + ); } function handleFiles(newFiles: FileList) { - const validFiles = Array.from(newFiles).filter(isValidImageFile); + const validFiles = Array.from(newFiles).filter(isValidImageFile); - if (validFiles.length < newFiles.length) { - showAlert('Invalid Files', 'Some files were skipped. Only JPG, JPEG, JP2, and JPX files are allowed.'); - } + if (validFiles.length < newFiles.length) { + showAlert( + 'Invalid Files', + 'Some files were skipped. Only JPG, JPEG, JP2, and JPX files are allowed.' + ); + } - if (validFiles.length > 0) { - files = [...files, ...validFiles]; - updateUI(); - } + if (validFiles.length > 0) { + files = [...files, ...validFiles]; + updateUI(); + } } const resetState = () => { - files = []; - updateUI(); + files = []; + updateUI(); }; function updateUI() { - const fileDisplayArea = document.getElementById('file-display-area'); - const fileControls = document.getElementById('file-controls'); - const optionsDiv = document.getElementById('jpg-to-pdf-options'); + const fileDisplayArea = document.getElementById('file-display-area'); + const fileControls = document.getElementById('file-controls'); + const optionsDiv = document.getElementById('jpg-to-pdf-options'); - if (!fileDisplayArea || !fileControls || !optionsDiv) return; + if (!fileDisplayArea || !fileControls || !optionsDiv) return; - fileDisplayArea.innerHTML = ''; + fileDisplayArea.innerHTML = ''; - if (files.length > 0) { - fileControls.classList.remove('hidden'); - optionsDiv.classList.remove('hidden'); + if (files.length > 0) { + fileControls.classList.remove('hidden'); + optionsDiv.classList.remove('hidden'); - files.forEach((file, index) => { - const fileDiv = document.createElement('div'); - fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm'; + files.forEach((file, index) => { + const fileDiv = document.createElement('div'); + fileDiv.className = + 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm'; - const infoContainer = document.createElement('div'); - infoContainer.className = 'flex items-center gap-2 overflow-hidden'; + const infoContainer = document.createElement('div'); + infoContainer.className = 'flex items-center gap-2 overflow-hidden'; - const nameSpan = document.createElement('span'); - nameSpan.className = 'truncate font-medium text-gray-200'; - nameSpan.textContent = file.name; + const nameSpan = document.createElement('span'); + nameSpan.className = 'truncate font-medium text-gray-200'; + nameSpan.textContent = file.name; - const sizeSpan = document.createElement('span'); - sizeSpan.className = 'flex-shrink-0 text-gray-400 text-xs'; - sizeSpan.textContent = `(${formatBytes(file.size)})`; + const sizeSpan = document.createElement('span'); + sizeSpan.className = 'flex-shrink-0 text-gray-400 text-xs'; + sizeSpan.textContent = `(${formatBytes(file.size)})`; - infoContainer.append(nameSpan, sizeSpan); + infoContainer.append(nameSpan, sizeSpan); - const removeBtn = document.createElement('button'); - removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0'; - removeBtn.innerHTML = ''; - removeBtn.onclick = () => { - files = files.filter((_, i) => i !== index); - updateUI(); - }; + const removeBtn = document.createElement('button'); + removeBtn.className = + 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0'; + removeBtn.innerHTML = ''; + removeBtn.onclick = () => { + files = files.filter((_, i) => i !== index); + updateUI(); + }; - fileDiv.append(infoContainer, removeBtn); - fileDisplayArea.appendChild(fileDiv); - }); - createIcons({ icons }); - } else { - fileControls.classList.add('hidden'); - optionsDiv.classList.add('hidden'); - } + fileDiv.append(infoContainer, removeBtn); + fileDisplayArea.appendChild(fileDiv); + }); + createIcons({ icons }); + } else { + fileControls.classList.add('hidden'); + optionsDiv.classList.add('hidden'); + } } -async function ensurePyMuPDF(): Promise { - if (!pymupdf) { - pymupdf = new PyMuPDF(getWasmBaseUrl('pymupdf')); - await pymupdf.load(); - } - return pymupdf; +async function ensurePyMuPDF(): Promise { + if (!pymupdf) { + pymupdf = await loadPyMuPDF(); + } + return pymupdf; } async function convertToPdf() { - if (files.length === 0) { - showAlert('No Files', 'Please select at least one JPG or JPEG2000 image.'); - return; + if (files.length === 0) { + showAlert('No Files', 'Please select at least one JPG or JPEG2000 image.'); + return; + } + + showLoader('Loading engine...'); + + try { + const mupdf = await ensurePyMuPDF(); + + showLoader('Converting images to PDF...'); + const quality = getSelectedQuality(); + const compressedFiles: File[] = []; + for (const file of files) { + compressedFiles.push(await compressImageFile(file, quality)); } - showLoader('Loading engine...'); + const pdfBlob = await mupdf.imagesToPdf(compressedFiles); - try { - const mupdf = await ensurePyMuPDF(); + downloadFile(pdfBlob, 'from_jpgs.pdf'); - showLoader('Converting images to PDF...'); - - const pdfBlob = await mupdf.imagesToPdf(files); - - downloadFile(pdfBlob, 'from_jpgs.pdf'); - - showAlert('Success', 'PDF created successfully!', 'success', () => { - resetState(); - }); - } catch (e: any) { - console.error('[JpgToPdf]', e); - showAlert('Conversion Error', e.message || 'Failed to convert images to PDF.'); - } finally { - hideLoader(); - } + showAlert('Success', 'PDF created successfully!', 'success', () => { + resetState(); + }); + } catch (e: any) { + console.error('[JpgToPdf]', e); + showAlert( + 'Conversion Error', + e.message || 'Failed to convert images to PDF.' + ); + } finally { + hideLoader(); + } } diff --git a/src/js/logic/json-to-pdf.ts b/src/js/logic/json-to-pdf.ts index e483781..0c9735d 100644 --- a/src/js/logic/json-to-pdf.ts +++ b/src/js/logic/json-to-pdf.ts @@ -1,101 +1,133 @@ -import JSZip from 'jszip' -import { downloadFile, formatBytes, readFileAsArrayBuffer } from '../utils/helpers'; +import JSZip from 'jszip'; +import { + downloadFile, + formatBytes, + readFileAsArrayBuffer, +} from '../utils/helpers'; import { initializeGlobalShortcuts } from '../utils/shortcuts-init.js'; +import { isCpdfAvailable } from '../utils/cpdf-helper.js'; +import { + showWasmRequiredDialog, + WasmProvider, +} from '../utils/wasm-provider.js'; -const worker = new Worker(import.meta.env.BASE_URL + 'workers/json-to-pdf.worker.js'); +const worker = new Worker( + import.meta.env.BASE_URL + 'workers/json-to-pdf.worker.js' +); -let selectedFiles: File[] = [] +let selectedFiles: File[] = []; -const jsonFilesInput = document.getElementById('jsonFiles') as HTMLInputElement -const convertBtn = document.getElementById('convertBtn') as HTMLButtonElement -const statusMessage = document.getElementById('status-message') as HTMLDivElement -const fileListDiv = document.getElementById('fileList') as HTMLDivElement -const backToToolsBtn = document.getElementById('back-to-tools') as HTMLButtonElement +const jsonFilesInput = document.getElementById('jsonFiles') as HTMLInputElement; +const convertBtn = document.getElementById('convertBtn') as HTMLButtonElement; +const statusMessage = document.getElementById( + 'status-message' +) as HTMLDivElement; +const fileListDiv = document.getElementById('fileList') as HTMLDivElement; +const backToToolsBtn = document.getElementById( + 'back-to-tools' +) as HTMLButtonElement; function showStatus( message: string, type: 'success' | 'error' | 'info' = 'info' ) { - statusMessage.textContent = message - statusMessage.className = `mt-4 p-3 rounded-lg text-sm ${type === 'success' - ? 'bg-green-900 text-green-200' - : type === 'error' - ? 'bg-red-900 text-red-200' - : 'bg-blue-900 text-blue-200' - }` - statusMessage.classList.remove('hidden') + statusMessage.textContent = message; + statusMessage.className = `mt-4 p-3 rounded-lg text-sm ${ + type === 'success' + ? 'bg-green-900 text-green-200' + : type === 'error' + ? 'bg-red-900 text-red-200' + : 'bg-blue-900 text-blue-200' + }`; + statusMessage.classList.remove('hidden'); } function hideStatus() { - statusMessage.classList.add('hidden') + statusMessage.classList.add('hidden'); } function updateFileList() { - fileListDiv.innerHTML = '' + fileListDiv.innerHTML = ''; if (selectedFiles.length === 0) { - fileListDiv.classList.add('hidden') - return + fileListDiv.classList.add('hidden'); + return; } - fileListDiv.classList.remove('hidden') + fileListDiv.classList.remove('hidden'); selectedFiles.forEach((file) => { - const fileDiv = document.createElement('div') - fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm mb-2' + const fileDiv = document.createElement('div'); + fileDiv.className = + 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm mb-2'; - const nameSpan = document.createElement('span') - nameSpan.className = 'truncate font-medium text-gray-200' - nameSpan.textContent = file.name + const nameSpan = document.createElement('span'); + nameSpan.className = 'truncate font-medium text-gray-200'; + nameSpan.textContent = file.name; - const sizeSpan = document.createElement('span') - sizeSpan.className = 'flex-shrink-0 ml-4 text-gray-400' - sizeSpan.textContent = formatBytes(file.size) + const sizeSpan = document.createElement('span'); + sizeSpan.className = 'flex-shrink-0 ml-4 text-gray-400'; + sizeSpan.textContent = formatBytes(file.size); - fileDiv.append(nameSpan, sizeSpan) - fileListDiv.appendChild(fileDiv) - }) + fileDiv.append(nameSpan, sizeSpan); + fileListDiv.appendChild(fileDiv); + }); } jsonFilesInput.addEventListener('change', (e) => { - const target = e.target as HTMLInputElement + const target = e.target as HTMLInputElement; if (target.files && target.files.length > 0) { - selectedFiles = Array.from(target.files) - convertBtn.disabled = selectedFiles.length === 0 - updateFileList() + selectedFiles = Array.from(target.files); + convertBtn.disabled = selectedFiles.length === 0; + updateFileList(); if (selectedFiles.length === 0) { - showStatus('Please select at least 1 JSON file', 'info') + showStatus('Please select at least 1 JSON file', 'info'); } else { - showStatus(`${selectedFiles.length} file(s) selected. Ready to convert!`, 'info') + showStatus( + `${selectedFiles.length} file(s) selected. Ready to convert!`, + 'info' + ); } } -}) +}); async function convertJSONsToPDF() { if (selectedFiles.length === 0) { - showStatus('Please select at least 1 JSON file', 'error') - return + showStatus('Please select at least 1 JSON file', 'error'); + return; + } + + // Check if CPDF is configured + if (!isCpdfAvailable()) { + showWasmRequiredDialog('cpdf'); + return; } try { - convertBtn.disabled = true - showStatus('Reading files (Main Thread)...', 'info') + convertBtn.disabled = true; + showStatus('Reading files (Main Thread)...', 'info'); const fileBuffers = await Promise.all( - selectedFiles.map(file => readFileAsArrayBuffer(file)) - ) + selectedFiles.map((file) => readFileAsArrayBuffer(file)) + ); - showStatus('Converting JSONs to PDFs...', 'info') - - worker.postMessage({ - command: 'convert', - fileBuffers: fileBuffers, - fileNames: selectedFiles.map(f => f.name) - }, fileBuffers); + showStatus('Converting JSONs to PDFs...', 'info'); + worker.postMessage( + { + command: 'convert', + fileBuffers: fileBuffers, + fileNames: selectedFiles.map((f) => f.name), + cpdfUrl: WasmProvider.getUrl('cpdf')! + 'coherentpdf.browser.min.js', + }, + fileBuffers + ); } catch (error) { - console.error('Error reading files:', error) - showStatus(`❌ Error reading files: ${error instanceof Error ? error.message : 'Unknown error'}`, 'error') - convertBtn.disabled = false + console.error('Error reading files:', error); + showStatus( + `❌ Error reading files: ${error instanceof Error ? error.message : 'Unknown error'}`, + 'error' + ); + convertBtn.disabled = false; } } @@ -103,42 +135,49 @@ worker.onmessage = async (e: MessageEvent) => { convertBtn.disabled = false; if (e.data.status === 'success') { - const pdfFiles = e.data.pdfFiles as Array<{ name: string, data: ArrayBuffer }>; + const pdfFiles = e.data.pdfFiles as Array<{ + name: string; + data: ArrayBuffer; + }>; try { - showStatus('Creating ZIP file...', 'info') + showStatus('Creating ZIP file...', 'info'); - const zip = new JSZip() + const zip = new JSZip(); pdfFiles.forEach(({ name, data }) => { - const pdfName = name.replace(/\.json$/i, '.pdf') - const uint8Array = new Uint8Array(data) - zip.file(pdfName, uint8Array) - }) + const pdfName = name.replace(/\.json$/i, '.pdf'); + const uint8Array = new Uint8Array(data); + zip.file(pdfName, uint8Array); + }); - const zipBlob = await zip.generateAsync({ type: 'blob' }) - const url = URL.createObjectURL(zipBlob) - const a = document.createElement('a') - a.href = url - a.download = 'jsons-to-pdf.zip' - downloadFile(zipBlob, 'jsons-to-pdf.zip') + const zipBlob = await zip.generateAsync({ type: 'blob' }); + const url = URL.createObjectURL(zipBlob); + const a = document.createElement('a'); + a.href = url; + a.download = 'jsons-to-pdf.zip'; + downloadFile(zipBlob, 'jsons-to-pdf.zip'); - showStatus('✅ JSONs converted to PDF successfully! ZIP download started.', 'success') + showStatus( + '✅ JSONs converted to PDF successfully! ZIP download started.', + 'success' + ); - selectedFiles = [] - jsonFilesInput.value = '' - fileListDiv.innerHTML = '' - fileListDiv.classList.add('hidden') - convertBtn.disabled = true + selectedFiles = []; + jsonFilesInput.value = ''; + fileListDiv.innerHTML = ''; + fileListDiv.classList.add('hidden'); + convertBtn.disabled = true; setTimeout(() => { - hideStatus() - }, 3000) - + hideStatus(); + }, 3000); } catch (error) { - console.error('Error creating ZIP:', error) - showStatus(`❌ Error creating ZIP: ${error instanceof Error ? error.message : 'Unknown error'}`, 'error') + console.error('Error creating ZIP:', error); + showStatus( + `❌ Error creating ZIP: ${error instanceof Error ? error.message : 'Unknown error'}`, + 'error' + ); } - } else if (e.data.status === 'error') { const errorMessage = e.data.message || 'Unknown error occurred in worker.'; console.error('Worker Error:', errorMessage); @@ -148,12 +187,12 @@ worker.onmessage = async (e: MessageEvent) => { if (backToToolsBtn) { backToToolsBtn.addEventListener('click', () => { - window.location.href = import.meta.env.BASE_URL - }) + window.location.href = import.meta.env.BASE_URL; + }); } -convertBtn.addEventListener('click', convertJSONsToPDF) +convertBtn.addEventListener('click', convertJSONsToPDF); // Initialize -showStatus('Select JSON files to get started', 'info') -initializeGlobalShortcuts() +showStatus('Select JSON files to get started', 'info'); +initializeGlobalShortcuts(); diff --git a/src/js/logic/merge-pdf-page.ts b/src/js/logic/merge-pdf-page.ts index 85c6a23..714aa04 100644 --- a/src/js/logic/merge-pdf-page.ts +++ b/src/js/logic/merge-pdf-page.ts @@ -1,629 +1,676 @@ import { showLoader, hideLoader, showAlert } from '../ui.js'; -import { downloadFile, readFileAsArrayBuffer, getPDFDocument } from '../utils/helpers.js'; +import { + downloadFile, + readFileAsArrayBuffer, + getPDFDocument, +} from '../utils/helpers.js'; import { state } from '../state.js'; -import { renderPagesProgressively, cleanupLazyRendering } from '../utils/render-utils.js'; +import { + renderPagesProgressively, + cleanupLazyRendering, +} from '../utils/render-utils.js'; +import { initPagePreview } from '../utils/page-preview.js'; +import { isCpdfAvailable } from '../utils/cpdf-helper.js'; +import { + showWasmRequiredDialog, + WasmProvider, +} from '../utils/wasm-provider.js'; import { createIcons, icons } from 'lucide'; import * as pdfjsLib from 'pdfjs-dist'; import Sortable from 'sortablejs'; // @ts-ignore -pdfjsLib.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).toString(); +pdfjsLib.GlobalWorkerOptions.workerSrc = new URL( + 'pdfjs-dist/build/pdf.worker.min.mjs', + import.meta.url +).toString(); interface MergeState { - pdfDocs: Record; - pdfBytes: Record; - activeMode: 'file' | 'page'; - sortableInstances: { - fileList?: Sortable; - pageThumbnails?: Sortable; - }; - isRendering: boolean; - cachedThumbnails: boolean | null; - lastFileHash: string | null; - mergeSuccess: boolean; + pdfDocs: Record; + pdfBytes: Record; + activeMode: 'file' | 'page'; + sortableInstances: { + fileList?: Sortable; + pageThumbnails?: Sortable; + }; + isRendering: boolean; + cachedThumbnails: boolean | null; + lastFileHash: string | null; + mergeSuccess: boolean; } const mergeState: MergeState = { - pdfDocs: {}, - pdfBytes: {}, - activeMode: 'file', - sortableInstances: {}, - isRendering: false, - cachedThumbnails: null, - lastFileHash: null, - mergeSuccess: false, + pdfDocs: {}, + pdfBytes: {}, + activeMode: 'file', + sortableInstances: {}, + isRendering: false, + cachedThumbnails: null, + lastFileHash: null, + mergeSuccess: false, }; -const mergeWorker = new Worker(import.meta.env.BASE_URL + 'workers/merge.worker.js'); +const mergeWorker = new Worker( + import.meta.env.BASE_URL + 'workers/merge.worker.js' +); function initializeFileListSortable() { - const fileList = document.getElementById('file-list'); - if (!fileList) return; + const fileList = document.getElementById('file-list'); + if (!fileList) return; - if (mergeState.sortableInstances.fileList) { - mergeState.sortableInstances.fileList.destroy(); - } + if (mergeState.sortableInstances.fileList) { + mergeState.sortableInstances.fileList.destroy(); + } - mergeState.sortableInstances.fileList = Sortable.create(fileList, { - handle: '.drag-handle', - animation: 150, - ghostClass: 'sortable-ghost', - chosenClass: 'sortable-chosen', - dragClass: 'sortable-drag', - onStart: function (evt: any) { - evt.item.style.opacity = '0.5'; - }, - onEnd: function (evt: any) { - evt.item.style.opacity = '1'; - }, - }); + mergeState.sortableInstances.fileList = Sortable.create(fileList, { + handle: '.drag-handle', + animation: 150, + ghostClass: 'sortable-ghost', + chosenClass: 'sortable-chosen', + dragClass: 'sortable-drag', + onStart: function (evt: any) { + evt.item.style.opacity = '0.5'; + }, + onEnd: function (evt: any) { + evt.item.style.opacity = '1'; + }, + }); } function initializePageThumbnailsSortable() { - const container = document.getElementById('page-merge-preview'); - if (!container) return; + const container = document.getElementById('page-merge-preview'); + if (!container) return; - if (mergeState.sortableInstances.pageThumbnails) { - mergeState.sortableInstances.pageThumbnails.destroy(); - } + if (mergeState.sortableInstances.pageThumbnails) { + mergeState.sortableInstances.pageThumbnails.destroy(); + } - mergeState.sortableInstances.pageThumbnails = Sortable.create(container, { - animation: 150, - ghostClass: 'sortable-ghost', - chosenClass: 'sortable-chosen', - dragClass: 'sortable-drag', - onStart: function (evt: any) { - evt.item.style.opacity = '0.5'; - }, - onEnd: function (evt: any) { - evt.item.style.opacity = '1'; - }, - }); + mergeState.sortableInstances.pageThumbnails = Sortable.create(container, { + animation: 150, + ghostClass: 'sortable-ghost', + chosenClass: 'sortable-chosen', + dragClass: 'sortable-drag', + onStart: function (evt: any) { + evt.item.style.opacity = '0.5'; + }, + onEnd: function (evt: any) { + evt.item.style.opacity = '1'; + }, + }); } function generateFileHash() { - return (state.files as File[]) - .map((f) => `${f.name}-${f.size}-${f.lastModified}`) - .join('|'); + return (state.files as File[]) + .map((f) => `${f.name}-${f.size}-${f.lastModified}`) + .join('|'); } async function renderPageMergeThumbnails() { - const container = document.getElementById('page-merge-preview'); - if (!container) return; + const container = document.getElementById('page-merge-preview'); + if (!container) return; - const currentFileHash = generateFileHash(); - const filesChanged = currentFileHash !== mergeState.lastFileHash; + const currentFileHash = generateFileHash(); + const filesChanged = currentFileHash !== mergeState.lastFileHash; - if (!filesChanged && mergeState.cachedThumbnails !== null) { - // Simple check to see if it's already rendered to avoid flicker. - if (container.firstChild) { - initializePageThumbnailsSortable(); - return; - } + if (!filesChanged && mergeState.cachedThumbnails !== null) { + // Simple check to see if it's already rendered to avoid flicker. + if (container.firstChild) { + initializePageThumbnailsSortable(); + return; } + } - if (mergeState.isRendering) { - return; - } + if (mergeState.isRendering) { + return; + } - mergeState.isRendering = true; - container.textContent = ''; + mergeState.isRendering = true; + container.textContent = ''; - cleanupLazyRendering(); + cleanupLazyRendering(); - let totalPages = 0; + let totalPages = 0; + for (const file of state.files) { + const doc = mergeState.pdfDocs[file.name]; + if (doc) totalPages += doc.numPages; + } + + try { + let currentPageNumber = 0; + + // Function to create wrapper element for each page + const createWrapper = ( + canvas: HTMLCanvasElement, + pageNumber: number, + fileName?: string + ) => { + const wrapper = document.createElement('div'); + wrapper.className = + 'page-thumbnail relative cursor-move flex flex-col items-center gap-1 p-2 border-2 border-gray-600 hover:border-indigo-500 rounded-lg bg-gray-700 transition-colors'; + wrapper.dataset.fileName = fileName || ''; + wrapper.dataset.pageIndex = (pageNumber - 1).toString(); + + const imgContainer = document.createElement('div'); + imgContainer.className = 'relative'; + + const img = document.createElement('img'); + img.src = canvas.toDataURL(); + img.className = 'rounded-md shadow-md max-w-full h-auto'; + + const pageNumDiv = document.createElement('div'); + pageNumDiv.className = + 'absolute top-1 left-1 bg-indigo-600 text-white text-xs px-2 py-1 rounded-md font-semibold shadow-lg'; + pageNumDiv.textContent = pageNumber.toString(); + + imgContainer.append(img, pageNumDiv); + + const fileNamePara = document.createElement('p'); + fileNamePara.className = + 'text-xs text-gray-400 truncate w-full text-center'; + const fullTitle = fileName + ? `${fileName} (page ${pageNumber})` + : `Page ${pageNumber}`; + fileNamePara.title = fullTitle; + fileNamePara.textContent = fileName + ? `${fileName.substring(0, 10)}... (p${pageNumber})` + : `Page ${pageNumber}`; + + wrapper.append(imgContainer, fileNamePara); + return wrapper; + }; + + // Render pages from all files progressively for (const file of state.files) { - const doc = mergeState.pdfDocs[file.name]; - if (doc) totalPages += doc.numPages; - } + const pdfjsDoc = mergeState.pdfDocs[file.name]; + if (!pdfjsDoc) continue; - try { - let currentPageNumber = 0; + // Create a wrapper function that includes the file name + const createWrapperWithFileName = ( + canvas: HTMLCanvasElement, + pageNumber: number + ) => { + return createWrapper(canvas, pageNumber, file.name); + }; - // Function to create wrapper element for each page - const createWrapper = (canvas: HTMLCanvasElement, pageNumber: number, fileName?: string) => { - const wrapper = document.createElement('div'); - wrapper.className = - 'page-thumbnail relative cursor-move flex flex-col items-center gap-1 p-2 border-2 border-gray-600 hover:border-indigo-500 rounded-lg bg-gray-700 transition-colors'; - wrapper.dataset.fileName = fileName || ''; - wrapper.dataset.pageIndex = (pageNumber - 1).toString(); - - const imgContainer = document.createElement('div'); - imgContainer.className = 'relative'; - - const img = document.createElement('img'); - img.src = canvas.toDataURL(); - img.className = 'rounded-md shadow-md max-w-full h-auto'; - - const pageNumDiv = document.createElement('div'); - pageNumDiv.className = - 'absolute top-1 left-1 bg-indigo-600 text-white text-xs px-2 py-1 rounded-md font-semibold shadow-lg'; - pageNumDiv.textContent = pageNumber.toString(); - - imgContainer.append(img, pageNumDiv); - - const fileNamePara = document.createElement('p'); - fileNamePara.className = - 'text-xs text-gray-400 truncate w-full text-center'; - const fullTitle = fileName ? `${fileName} (page ${pageNumber})` : `Page ${pageNumber}`; - fileNamePara.title = fullTitle; - fileNamePara.textContent = fileName - ? `${fileName.substring(0, 10)}... (p${pageNumber})` - : `Page ${pageNumber}`; - - wrapper.append(imgContainer, fileNamePara); - return wrapper; - }; - - // Render pages from all files progressively - for (const file of state.files) { - const pdfjsDoc = mergeState.pdfDocs[file.name]; - if (!pdfjsDoc) continue; - - // Create a wrapper function that includes the file name - const createWrapperWithFileName = (canvas: HTMLCanvasElement, pageNumber: number) => { - return createWrapper(canvas, pageNumber, file.name); - }; - - // Render pages progressively with lazy loading - await renderPagesProgressively( - pdfjsDoc, - container, - createWrapperWithFileName, - { - batchSize: 8, - useLazyLoading: true, - lazyLoadMargin: '300px', - onProgress: (current, total) => { - currentPageNumber++; - showLoader( - `Rendering page previews...` - ); - }, - onBatchComplete: () => { - createIcons({ icons }); - } - } - ); + // Render pages progressively with lazy loading + await renderPagesProgressively( + pdfjsDoc, + container, + createWrapperWithFileName, + { + batchSize: 8, + useLazyLoading: true, + lazyLoadMargin: '300px', + onProgress: (current, total) => { + currentPageNumber++; + showLoader(`Rendering page previews...`); + }, + onBatchComplete: () => { + createIcons({ icons }); + }, } + ); - mergeState.cachedThumbnails = true; - mergeState.lastFileHash = currentFileHash; - - initializePageThumbnailsSortable(); - } catch (error) { - console.error('Error rendering page thumbnails:', error); - showAlert('Error', 'Failed to render page thumbnails'); - } finally { - hideLoader(); - mergeState.isRendering = false; + initPagePreview(container, pdfjsDoc); } + + mergeState.cachedThumbnails = true; + mergeState.lastFileHash = currentFileHash; + + initializePageThumbnailsSortable(); + } catch (error) { + console.error('Error rendering page thumbnails:', error); + showAlert('Error', 'Failed to render page thumbnails'); + } finally { + hideLoader(); + mergeState.isRendering = false; + } } const updateUI = async () => { - const fileControls = document.getElementById('file-controls'); - const mergeOptions = document.getElementById('merge-options'); + const fileControls = document.getElementById('file-controls'); + const mergeOptions = document.getElementById('merge-options'); - if (state.files.length > 0) { - if (fileControls) fileControls.classList.remove('hidden'); - if (mergeOptions) mergeOptions.classList.remove('hidden'); - await refreshMergeUI(); - } else { - if (fileControls) fileControls.classList.add('hidden'); - if (mergeOptions) mergeOptions.classList.add('hidden'); - // Clear file list UI - const fileList = document.getElementById('file-list'); - if (fileList) fileList.innerHTML = ''; - } + if (state.files.length > 0) { + if (fileControls) fileControls.classList.remove('hidden'); + if (mergeOptions) mergeOptions.classList.remove('hidden'); + await refreshMergeUI(); + } else { + if (fileControls) fileControls.classList.add('hidden'); + if (mergeOptions) mergeOptions.classList.add('hidden'); + // Clear file list UI + const fileList = document.getElementById('file-list'); + if (fileList) fileList.innerHTML = ''; + } }; const resetState = async () => { - state.files = []; - state.pdfDoc = null; + state.files = []; + state.pdfDoc = null; - mergeState.pdfDocs = {}; - mergeState.pdfBytes = {}; - mergeState.activeMode = 'file'; - mergeState.cachedThumbnails = null; - mergeState.lastFileHash = null; - mergeState.mergeSuccess = false; + mergeState.pdfDocs = {}; + mergeState.pdfBytes = {}; + mergeState.activeMode = 'file'; + mergeState.cachedThumbnails = null; + mergeState.lastFileHash = null; + mergeState.mergeSuccess = false; - const fileList = document.getElementById('file-list'); - if (fileList) fileList.innerHTML = ''; + const fileList = document.getElementById('file-list'); + if (fileList) fileList.innerHTML = ''; - const pageMergePreview = document.getElementById('page-merge-preview'); - if (pageMergePreview) pageMergePreview.innerHTML = ''; + const pageMergePreview = document.getElementById('page-merge-preview'); + if (pageMergePreview) pageMergePreview.innerHTML = ''; - const fileModeBtn = document.getElementById('file-mode-btn'); - const pageModeBtn = document.getElementById('page-mode-btn'); - const filePanel = document.getElementById('file-mode-panel'); - const pagePanel = document.getElementById('page-mode-panel'); + const fileModeBtn = document.getElementById('file-mode-btn'); + const pageModeBtn = document.getElementById('page-mode-btn'); + const filePanel = document.getElementById('file-mode-panel'); + const pagePanel = document.getElementById('page-mode-panel'); - if (fileModeBtn && pageModeBtn && filePanel && pagePanel) { - fileModeBtn.classList.add('bg-indigo-600', 'text-white'); - fileModeBtn.classList.remove('bg-gray-700', 'text-gray-300'); - pageModeBtn.classList.remove('bg-indigo-600', 'text-white'); - pageModeBtn.classList.add('bg-gray-700', 'text-gray-300'); + if (fileModeBtn && pageModeBtn && filePanel && pagePanel) { + fileModeBtn.classList.add('bg-indigo-600', 'text-white'); + fileModeBtn.classList.remove('bg-gray-700', 'text-gray-300'); + pageModeBtn.classList.remove('bg-indigo-600', 'text-white'); + pageModeBtn.classList.add('bg-gray-700', 'text-gray-300'); - filePanel.classList.remove('hidden'); - pagePanel.classList.add('hidden'); - } + filePanel.classList.remove('hidden'); + pagePanel.classList.add('hidden'); + } - await updateUI(); + await updateUI(); }; - export async function merge() { - showLoader('Merging PDFs...'); - try { - // @ts-ignore - const jobs: MergeJob[] = []; - // @ts-ignore - const filesToMerge: MergeFile[] = []; - const uniqueFileNames = new Set(); + // Check if CPDF is configured + if (!isCpdfAvailable()) { + showWasmRequiredDialog('cpdf'); + return; + } - if (mergeState.activeMode === 'file') { - const fileList = document.getElementById('file-list'); - if (!fileList) throw new Error('File list not found'); + showLoader('Merging PDFs...'); + try { + // @ts-ignore + const jobs: MergeJob[] = []; + // @ts-ignore + const filesToMerge: MergeFile[] = []; + const uniqueFileNames = new Set(); - const sortedFiles = Array.from(fileList.children) - .map((li) => { - return state.files.find((f) => f.name === (li as HTMLElement).dataset.fileName); - }) - .filter(Boolean); + if (mergeState.activeMode === 'file') { + const fileList = document.getElementById('file-list'); + if (!fileList) throw new Error('File list not found'); - for (const file of sortedFiles) { - if (!file) continue; - const safeFileName = file.name.replace(/[^a-zA-Z0-9]/g, '_'); - const rangeInput = document.getElementById(`range-${safeFileName}`) as HTMLInputElement; + const sortedFiles = Array.from(fileList.children) + .map((li) => { + return state.files.find( + (f) => f.name === (li as HTMLElement).dataset.fileName + ); + }) + .filter(Boolean); - uniqueFileNames.add(file.name); + for (const file of sortedFiles) { + if (!file) continue; + const safeFileName = file.name.replace(/[^a-zA-Z0-9]/g, '_'); + const rangeInput = document.getElementById( + `range-${safeFileName}` + ) as HTMLInputElement; - if (rangeInput && rangeInput.value.trim()) { - jobs.push({ - fileName: file.name, - rangeType: 'specific', - rangeString: rangeInput.value.trim() - }); - } else { - jobs.push({ - fileName: file.name, - rangeType: 'all' - }); - } - } + uniqueFileNames.add(file.name); + + if (rangeInput && rangeInput.value.trim()) { + jobs.push({ + fileName: file.name, + rangeType: 'specific', + rangeString: rangeInput.value.trim(), + }); } else { - // Page Mode - const pageContainer = document.getElementById('page-merge-preview'); - if (!pageContainer) throw new Error('Page container not found'); - const pageElements = Array.from(pageContainer.children); + jobs.push({ + fileName: file.name, + rangeType: 'all', + }); + } + } + } else { + // Page Mode + const pageContainer = document.getElementById('page-merge-preview'); + if (!pageContainer) throw new Error('Page container not found'); + const pageElements = Array.from(pageContainer.children); - const rawPages: { fileName: string; pageIndex: number }[] = []; - for (const el of pageElements) { - const element = el as HTMLElement; - const fileName = element.dataset.fileName; - const pageIndex = parseInt(element.dataset.pageIndex || '', 10); // 0-based index from dataset + const rawPages: { fileName: string; pageIndex: number }[] = []; + for (const el of pageElements) { + const element = el as HTMLElement; + const fileName = element.dataset.fileName; + const pageIndex = parseInt(element.dataset.pageIndex || '', 10); // 0-based index from dataset - if (fileName && !isNaN(pageIndex)) { - uniqueFileNames.add(fileName); - rawPages.push({ fileName, pageIndex }); - } - } + if (fileName && !isNaN(pageIndex)) { + uniqueFileNames.add(fileName); + rawPages.push({ fileName, pageIndex }); + } + } - // Group contiguous pages - for (let i = 0; i < rawPages.length; i++) { - const current = rawPages[i]; - let endPage = current.pageIndex; + // Group contiguous pages + for (let i = 0; i < rawPages.length; i++) { + const current = rawPages[i]; + let endPage = current.pageIndex; - while ( - i + 1 < rawPages.length && - rawPages[i + 1].fileName === current.fileName && - rawPages[i + 1].pageIndex === endPage + 1 - ) { - endPage++; - i++; - } - - if (endPage === current.pageIndex) { - // Single page - jobs.push({ - fileName: current.fileName, - rangeType: 'single', - pageIndex: current.pageIndex - }); - } else { - // Range of pages - jobs.push({ - fileName: current.fileName, - rangeType: 'range', - startPage: current.pageIndex + 1, - endPage: endPage + 1 - }); - } - } + while ( + i + 1 < rawPages.length && + rawPages[i + 1].fileName === current.fileName && + rawPages[i + 1].pageIndex === endPage + 1 + ) { + endPage++; + i++; } - if (jobs.length === 0) { - showAlert('Error', 'No files or pages selected to merge.'); - hideLoader(); - return; + if (endPage === current.pageIndex) { + // Single page + jobs.push({ + fileName: current.fileName, + rangeType: 'single', + pageIndex: current.pageIndex, + }); + } else { + // Range of pages + jobs.push({ + fileName: current.fileName, + rangeType: 'range', + startPage: current.pageIndex + 1, + endPage: endPage + 1, + }); } - - for (const name of uniqueFileNames) { - const bytes = mergeState.pdfBytes[name]; - if (bytes) { - filesToMerge.push({ name, data: bytes }); - } - } - - // @ts-ignore - const message: MergeMessage = { - command: 'merge', - files: filesToMerge, - jobs: jobs - }; - - mergeWorker.postMessage(message, filesToMerge.map(f => f.data)); - - // @ts-ignore - mergeWorker.onmessage = (e: MessageEvent) => { - hideLoader(); - if (e.data.status === 'success') { - const blob = new Blob([e.data.pdfBytes], { type: 'application/pdf' }); - downloadFile(blob, 'merged.pdf'); - mergeState.mergeSuccess = true; - showAlert('Success', 'PDFs merged successfully!', 'success', async () => { - await resetState(); - }); - } else { - console.error('Worker merge error:', e.data.message); - showAlert('Error', e.data.message || 'Failed to merge PDFs.'); - } - }; - - mergeWorker.onerror = (e) => { - hideLoader(); - console.error('Worker error:', e); - showAlert('Error', 'An unexpected error occurred in the merge worker.'); - }; - - } catch (e) { - console.error('Merge error:', e); - showAlert( - 'Error', - 'Failed to merge PDFs. Please check that all files are valid and not password-protected.' - ); - hideLoader(); + } } + + if (jobs.length === 0) { + showAlert('Error', 'No files or pages selected to merge.'); + hideLoader(); + return; + } + + for (const name of uniqueFileNames) { + const bytes = mergeState.pdfBytes[name]; + if (bytes) { + filesToMerge.push({ name, data: bytes }); + } + } + + // @ts-ignore + const message: MergeMessage = { + command: 'merge', + files: filesToMerge, + jobs: jobs, + cpdfUrl: WasmProvider.getUrl('cpdf')! + 'coherentpdf.browser.min.js', + }; + + mergeWorker.postMessage( + message, + filesToMerge.map((f) => f.data) + ); + + // @ts-ignore + mergeWorker.onmessage = (e: MessageEvent) => { + hideLoader(); + if (e.data.status === 'success') { + const blob = new Blob([e.data.pdfBytes], { type: 'application/pdf' }); + downloadFile(blob, 'merged.pdf'); + mergeState.mergeSuccess = true; + showAlert( + 'Success', + 'PDFs merged successfully!', + 'success', + async () => { + await resetState(); + } + ); + } else { + console.error('Worker merge error:', e.data.message); + showAlert('Error', e.data.message || 'Failed to merge PDFs.'); + } + }; + + mergeWorker.onerror = (e) => { + hideLoader(); + console.error('Worker error:', e); + showAlert('Error', 'An unexpected error occurred in the merge worker.'); + }; + } catch (e) { + console.error('Merge error:', e); + showAlert( + 'Error', + 'Failed to merge PDFs. Please check that all files are valid and not password-protected.' + ); + hideLoader(); + } } export async function refreshMergeUI() { - document.getElementById('merge-options')?.classList.remove('hidden'); - const processBtn = document.getElementById('process-btn') as HTMLButtonElement; - if (processBtn) processBtn.disabled = false; + document.getElementById('merge-options')?.classList.remove('hidden'); + const processBtn = document.getElementById( + 'process-btn' + ) as HTMLButtonElement; + if (processBtn) processBtn.disabled = false; - const wasInPageMode = mergeState.activeMode === 'page'; + const wasInPageMode = mergeState.activeMode === 'page'; - showLoader('Loading PDF documents...'); - try { - mergeState.pdfDocs = {}; - mergeState.pdfBytes = {}; + showLoader('Loading PDF documents...'); + try { + mergeState.pdfDocs = {}; + mergeState.pdfBytes = {}; - for (const file of state.files) { - const pdfBytes = await readFileAsArrayBuffer(file); - mergeState.pdfBytes[file.name] = pdfBytes as ArrayBuffer; + for (const file of state.files) { + const pdfBytes = await readFileAsArrayBuffer(file); + mergeState.pdfBytes[file.name] = pdfBytes as ArrayBuffer; - const bytesForPdfJs = (pdfBytes as ArrayBuffer).slice(0); - const pdfjsDoc = await getPDFDocument({ data: bytesForPdfJs }).promise; - mergeState.pdfDocs[file.name] = pdfjsDoc; - } - } catch (error) { - console.error('Error loading PDFs:', error); - showAlert('Error', 'Failed to load one or more PDF files'); - return; - } finally { - hideLoader(); + const bytesForPdfJs = (pdfBytes as ArrayBuffer).slice(0); + const pdfjsDoc = await getPDFDocument({ data: bytesForPdfJs }).promise; + mergeState.pdfDocs[file.name] = pdfjsDoc; } + } catch (error) { + console.error('Error loading PDFs:', error); + showAlert('Error', 'Failed to load one or more PDF files'); + return; + } finally { + hideLoader(); + } - const fileModeBtn = document.getElementById('file-mode-btn'); - const pageModeBtn = document.getElementById('page-mode-btn'); - const filePanel = document.getElementById('file-mode-panel'); - const pagePanel = document.getElementById('page-mode-panel'); - const fileList = document.getElementById('file-list'); + const fileModeBtn = document.getElementById('file-mode-btn'); + const pageModeBtn = document.getElementById('page-mode-btn'); + const filePanel = document.getElementById('file-mode-panel'); + const pagePanel = document.getElementById('page-mode-panel'); + const fileList = document.getElementById('file-list'); - if (!fileModeBtn || !pageModeBtn || !filePanel || !pagePanel || !fileList) return; + if (!fileModeBtn || !pageModeBtn || !filePanel || !pagePanel || !fileList) + return; - fileList.textContent = ''; // Clear list safely - (state.files as File[]).forEach((f, index) => { - const doc = mergeState.pdfDocs[f.name]; - const pageCount = doc ? doc.numPages : 'N/A'; - const safeFileName = f.name.replace(/[^a-zA-Z0-9]/g, '_'); + fileList.textContent = ''; // Clear list safely + (state.files as File[]).forEach((f, index) => { + const doc = mergeState.pdfDocs[f.name]; + const pageCount = doc ? doc.numPages : 'N/A'; + const safeFileName = f.name.replace(/[^a-zA-Z0-9]/g, '_'); - const li = document.createElement('li'); - li.className = - 'bg-gray-700 p-3 rounded-lg border border-gray-600 hover:border-indigo-500 transition-colors'; - li.dataset.fileName = f.name; + const li = document.createElement('li'); + li.className = + 'bg-gray-700 p-3 rounded-lg border border-gray-600 hover:border-indigo-500 transition-colors'; + li.dataset.fileName = f.name; - const mainDiv = document.createElement('div'); - mainDiv.className = 'flex items-center justify-between'; + const mainDiv = document.createElement('div'); + mainDiv.className = 'flex items-center justify-between'; - const nameSpan = document.createElement('span'); - nameSpan.className = 'truncate font-medium text-white flex-1 mr-2'; - nameSpan.title = f.name; - nameSpan.textContent = f.name; + const nameSpan = document.createElement('span'); + nameSpan.className = 'truncate font-medium text-white flex-1 mr-2'; + nameSpan.title = f.name; + nameSpan.textContent = f.name; - const dragHandle = document.createElement('div'); - dragHandle.className = - 'drag-handle cursor-move text-gray-400 hover:text-white p-1 rounded transition-colors'; - dragHandle.innerHTML = ``; // Safe: static content + const dragHandle = document.createElement('div'); + dragHandle.className = + 'drag-handle cursor-move text-gray-400 hover:text-white p-1 rounded transition-colors'; + dragHandle.innerHTML = ``; // Safe: static content - mainDiv.append(nameSpan, dragHandle); + mainDiv.append(nameSpan, dragHandle); - const rangeDiv = document.createElement('div'); - rangeDiv.className = 'mt-2 flex items-center gap-2'; + const rangeDiv = document.createElement('div'); + rangeDiv.className = 'mt-2 flex items-center gap-2'; - const inputWrapper = document.createElement('div'); - inputWrapper.className = 'flex-1'; + const inputWrapper = document.createElement('div'); + inputWrapper.className = 'flex-1'; - const label = document.createElement('label'); - label.htmlFor = `range-${safeFileName}`; - label.className = 'text-xs text-gray-400'; - label.textContent = `Pages (e.g., 1-3, 5) - Total: ${pageCount}`; + const label = document.createElement('label'); + label.htmlFor = `range-${safeFileName}`; + label.className = 'text-xs text-gray-400'; + label.textContent = `Pages (e.g., 1-3, 5) - Total: ${pageCount}`; - const input = document.createElement('input'); - input.type = 'text'; - input.id = `range-${safeFileName}`; - input.className = - 'w-full bg-gray-800 border border-gray-600 text-white rounded-md p-2 text-sm mt-1 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 transition-colors'; - input.placeholder = 'Leave blank for all pages'; + const input = document.createElement('input'); + input.type = 'text'; + input.id = `range-${safeFileName}`; + input.className = + 'w-full bg-gray-800 border border-gray-600 text-white rounded-md p-2 text-sm mt-1 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 transition-colors'; + input.placeholder = 'Leave blank for all pages'; - inputWrapper.append(label, input); + inputWrapper.append(label, input); - const deleteBtn = document.createElement('button'); - deleteBtn.className = 'text-red-400 hover:text-red-300 p-2 flex-shrink-0 self-end'; - deleteBtn.innerHTML = ''; - deleteBtn.title = 'Remove file'; - deleteBtn.onclick = (e) => { - e.stopPropagation(); - state.files = state.files.filter((_, i) => i !== index); - updateUI(); - }; + const deleteBtn = document.createElement('button'); + deleteBtn.className = + 'text-red-400 hover:text-red-300 p-2 flex-shrink-0 self-end'; + deleteBtn.innerHTML = ''; + deleteBtn.title = 'Remove file'; + deleteBtn.onclick = (e) => { + e.stopPropagation(); + state.files = state.files.filter((_, i) => i !== index); + updateUI(); + }; - rangeDiv.append(inputWrapper, deleteBtn); - li.append(mainDiv, rangeDiv); - fileList.appendChild(li); - }); + rangeDiv.append(inputWrapper, deleteBtn); + li.append(mainDiv, rangeDiv); + fileList.appendChild(li); + }); - createIcons({ icons }); - initializeFileListSortable(); + createIcons({ icons }); + initializeFileListSortable(); - const newFileModeBtn = fileModeBtn.cloneNode(true) as HTMLElement; - const newPageModeBtn = pageModeBtn.cloneNode(true) as HTMLElement; - fileModeBtn.replaceWith(newFileModeBtn); - pageModeBtn.replaceWith(newPageModeBtn); + const newFileModeBtn = fileModeBtn.cloneNode(true) as HTMLElement; + const newPageModeBtn = pageModeBtn.cloneNode(true) as HTMLElement; + fileModeBtn.replaceWith(newFileModeBtn); + pageModeBtn.replaceWith(newPageModeBtn); - newFileModeBtn.addEventListener('click', () => { - if (mergeState.activeMode === 'file') return; + newFileModeBtn.addEventListener('click', () => { + if (mergeState.activeMode === 'file') return; - mergeState.activeMode = 'file'; - filePanel.classList.remove('hidden'); - pagePanel.classList.add('hidden'); + mergeState.activeMode = 'file'; + filePanel.classList.remove('hidden'); + pagePanel.classList.add('hidden'); - newFileModeBtn.classList.add('bg-indigo-600', 'text-white'); - newFileModeBtn.classList.remove('bg-gray-700', 'text-gray-300'); - newPageModeBtn.classList.remove('bg-indigo-600', 'text-white'); - newPageModeBtn.classList.add('bg-gray-700', 'text-gray-300'); - }); + newFileModeBtn.classList.add('bg-indigo-600', 'text-white'); + newFileModeBtn.classList.remove('bg-gray-700', 'text-gray-300'); + newPageModeBtn.classList.remove('bg-indigo-600', 'text-white'); + newPageModeBtn.classList.add('bg-gray-700', 'text-gray-300'); + }); - newPageModeBtn.addEventListener('click', async () => { - if (mergeState.activeMode === 'page') return; + newPageModeBtn.addEventListener('click', async () => { + if (mergeState.activeMode === 'page') return; - mergeState.activeMode = 'page'; - filePanel.classList.add('hidden'); - pagePanel.classList.remove('hidden'); + mergeState.activeMode = 'page'; + filePanel.classList.add('hidden'); + pagePanel.classList.remove('hidden'); - newPageModeBtn.classList.add('bg-indigo-600', 'text-white'); - newPageModeBtn.classList.remove('bg-gray-700', 'text-gray-300'); - newFileModeBtn.classList.remove('bg-indigo-600', 'text-white'); - newFileModeBtn.classList.add('bg-gray-700', 'text-gray-300'); + newPageModeBtn.classList.add('bg-indigo-600', 'text-white'); + newPageModeBtn.classList.remove('bg-gray-700', 'text-gray-300'); + newFileModeBtn.classList.remove('bg-indigo-600', 'text-white'); + newFileModeBtn.classList.add('bg-gray-700', 'text-gray-300'); - await renderPageMergeThumbnails(); - }); + await renderPageMergeThumbnails(); + }); - if (wasInPageMode) { - mergeState.activeMode = 'page'; - filePanel.classList.add('hidden'); - pagePanel.classList.remove('hidden'); + if (wasInPageMode) { + mergeState.activeMode = 'page'; + filePanel.classList.add('hidden'); + pagePanel.classList.remove('hidden'); - newPageModeBtn.classList.add('bg-indigo-600', 'text-white'); - newPageModeBtn.classList.remove('bg-gray-700', 'text-gray-300'); - newFileModeBtn.classList.remove('bg-indigo-600', 'text-white'); - newFileModeBtn.classList.add('bg-gray-700', 'text-gray-300'); + newPageModeBtn.classList.add('bg-indigo-600', 'text-white'); + newPageModeBtn.classList.remove('bg-gray-700', 'text-gray-300'); + newFileModeBtn.classList.remove('bg-indigo-600', 'text-white'); + newFileModeBtn.classList.add('bg-gray-700', 'text-gray-300'); - await renderPageMergeThumbnails(); - } else { - newFileModeBtn.classList.add('bg-indigo-600', 'text-white'); - newPageModeBtn.classList.add('bg-gray-700', 'text-gray-300'); - } + await renderPageMergeThumbnails(); + } else { + newFileModeBtn.classList.add('bg-indigo-600', 'text-white'); + newPageModeBtn.classList.add('bg-gray-700', 'text-gray-300'); + } } document.addEventListener('DOMContentLoaded', () => { - const fileInput = document.getElementById('file-input') as HTMLInputElement; - const dropZone = document.getElementById('drop-zone'); - const processBtn = document.getElementById('process-btn'); + const fileInput = document.getElementById('file-input') as HTMLInputElement; + const dropZone = document.getElementById('drop-zone'); + const processBtn = document.getElementById('process-btn'); - const fileControls = document.getElementById('file-controls'); - const addMoreBtn = document.getElementById('add-more-btn'); - const clearFilesBtn = document.getElementById('clear-files-btn'); - const backBtn = document.getElementById('back-to-tools'); - const mergeOptions = document.getElementById('merge-options'); + const fileControls = document.getElementById('file-controls'); + const addMoreBtn = document.getElementById('add-more-btn'); + const clearFilesBtn = document.getElementById('clear-files-btn'); + const backBtn = document.getElementById('back-to-tools'); + const mergeOptions = document.getElementById('merge-options'); - if (backBtn) { - backBtn.addEventListener('click', () => { - window.location.href = import.meta.env.BASE_URL; - }); - } + if (backBtn) { + backBtn.addEventListener('click', () => { + window.location.href = import.meta.env.BASE_URL; + }); + } + if (fileInput && dropZone) { + fileInput.addEventListener('change', async (e) => { + const files = (e.target as HTMLInputElement).files; + if (files && files.length > 0) { + state.files = [...state.files, ...Array.from(files)]; + await updateUI(); + } + }); + dropZone.addEventListener('dragover', (e) => { + e.preventDefault(); + dropZone.classList.add('bg-gray-700'); + }); - if (fileInput && dropZone) { - fileInput.addEventListener('change', async (e) => { - const files = (e.target as HTMLInputElement).files; - if (files && files.length > 0) { - state.files = [...state.files, ...Array.from(files)]; - await updateUI(); - } - }); + dropZone.addEventListener('dragleave', (e) => { + e.preventDefault(); + dropZone.classList.remove('bg-gray-700'); + }); - dropZone.addEventListener('dragover', (e) => { - e.preventDefault(); - dropZone.classList.add('bg-gray-700'); - }); + dropZone.addEventListener('drop', async (e) => { + e.preventDefault(); + dropZone.classList.remove('bg-gray-700'); + const files = e.dataTransfer?.files; + if (files && files.length > 0) { + const pdfFiles = Array.from(files).filter( + (f) => + f.type === 'application/pdf' || + f.name.toLowerCase().endsWith('.pdf') + ); + if (pdfFiles.length > 0) { + state.files = [...state.files, ...pdfFiles]; + await updateUI(); + } + } + }); - dropZone.addEventListener('dragleave', (e) => { - e.preventDefault(); - dropZone.classList.remove('bg-gray-700'); - }); + fileInput.addEventListener('click', () => { + fileInput.value = ''; + }); + } - dropZone.addEventListener('drop', async (e) => { - e.preventDefault(); - dropZone.classList.remove('bg-gray-700'); - const files = e.dataTransfer?.files; - if (files && files.length > 0) { - const pdfFiles = Array.from(files).filter(f => f.type === 'application/pdf' || f.name.toLowerCase().endsWith('.pdf')); - if (pdfFiles.length > 0) { - state.files = [...state.files, ...pdfFiles]; - await updateUI(); - } - } - }); - - - fileInput.addEventListener('click', () => { - fileInput.value = ''; - }); - } - - if (addMoreBtn) { - addMoreBtn.addEventListener('click', () => { - fileInput.value = ''; - fileInput.click(); - }); - } - - if (clearFilesBtn) { - clearFilesBtn.addEventListener('click', async () => { - state.files = []; - await updateUI(); - }); - } - - if (processBtn) { - processBtn.addEventListener('click', async () => { - await merge(); - }); - } + if (addMoreBtn) { + addMoreBtn.addEventListener('click', () => { + fileInput.value = ''; + fileInput.click(); + }); + } + if (clearFilesBtn) { + clearFilesBtn.addEventListener('click', async () => { + state.files = []; + await updateUI(); + }); + } + if (processBtn) { + processBtn.addEventListener('click', async () => { + await merge(); + }); + } }); diff --git a/src/js/logic/mobi-to-pdf-page.ts b/src/js/logic/mobi-to-pdf-page.ts index f55dcc6..7ddd5c1 100644 --- a/src/js/logic/mobi-to-pdf-page.ts +++ b/src/js/logic/mobi-to-pdf-page.ts @@ -2,201 +2,212 @@ import { showLoader, hideLoader, showAlert } from '../ui.js'; import { downloadFile, formatBytes } from '../utils/helpers.js'; import { state } from '../state.js'; import { createIcons, icons } from 'lucide'; -import { PyMuPDF } from '@bentopdf/pymupdf-wasm'; -import { getWasmBaseUrl } from '../config/wasm-cdn-config.js'; +import { isWasmAvailable, getWasmBaseUrl } from '../config/wasm-cdn-config.js'; +import { showWasmRequiredDialog } from '../utils/wasm-provider.js'; +import { loadPyMuPDF, isPyMuPDFAvailable } from '../utils/pymupdf-loader.js'; const FILETYPE = 'mobi'; const EXTENSIONS = ['.mobi']; const TOOL_NAME = 'MOBI'; document.addEventListener('DOMContentLoaded', () => { - const fileInput = document.getElementById('file-input') as HTMLInputElement; - const dropZone = document.getElementById('drop-zone'); - const processBtn = document.getElementById('process-btn'); - const fileDisplayArea = document.getElementById('file-display-area'); - const fileControls = document.getElementById('file-controls'); - const addMoreBtn = document.getElementById('add-more-btn'); - const clearFilesBtn = document.getElementById('clear-files-btn'); - const backBtn = document.getElementById('back-to-tools'); + const fileInput = document.getElementById('file-input') as HTMLInputElement; + const dropZone = document.getElementById('drop-zone'); + const processBtn = document.getElementById('process-btn'); + const fileDisplayArea = document.getElementById('file-display-area'); + const fileControls = document.getElementById('file-controls'); + const addMoreBtn = document.getElementById('add-more-btn'); + const clearFilesBtn = document.getElementById('clear-files-btn'); + const backBtn = document.getElementById('back-to-tools'); - if (backBtn) { - backBtn.addEventListener('click', () => { - window.location.href = import.meta.env.BASE_URL; - }); + if (backBtn) { + backBtn.addEventListener('click', () => { + window.location.href = import.meta.env.BASE_URL; + }); + } + + const updateUI = async () => { + if (!fileDisplayArea || !processBtn || !fileControls) return; + + if (state.files.length > 0) { + fileDisplayArea.innerHTML = ''; + + for (let index = 0; index < state.files.length; index++) { + const file = state.files[index]; + const fileDiv = document.createElement('div'); + fileDiv.className = + 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm'; + + const infoContainer = document.createElement('div'); + infoContainer.className = 'flex flex-col overflow-hidden'; + + const nameSpan = document.createElement('div'); + nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1'; + nameSpan.textContent = file.name; + + const metaSpan = document.createElement('div'); + metaSpan.className = 'text-xs text-gray-400'; + metaSpan.textContent = formatBytes(file.size); + + 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 = () => { + state.files = state.files.filter((_, i) => i !== index); + updateUI(); + }; + + fileDiv.append(infoContainer, removeBtn); + fileDisplayArea.appendChild(fileDiv); + } + + createIcons({ icons }); + fileControls.classList.remove('hidden'); + processBtn.classList.remove('hidden'); + (processBtn as HTMLButtonElement).disabled = false; + } else { + fileDisplayArea.innerHTML = ''; + fileControls.classList.add('hidden'); + processBtn.classList.add('hidden'); + (processBtn as HTMLButtonElement).disabled = true; } + }; - const updateUI = async () => { - if (!fileDisplayArea || !processBtn || !fileControls) return; + const resetState = () => { + state.files = []; + state.pdfDoc = null; + updateUI(); + }; - if (state.files.length > 0) { - fileDisplayArea.innerHTML = ''; + const convertToPdf = async () => { + try { + if (state.files.length === 0) { + showAlert('No Files', `Please select at least one ${TOOL_NAME} file.`); + return; + } - for (let index = 0; index < state.files.length; index++) { - const file = state.files[index]; - const fileDiv = document.createElement('div'); - fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm'; + showLoader('Loading engine...'); + const pymupdf = await loadPyMuPDF(); - const infoContainer = document.createElement('div'); - infoContainer.className = 'flex flex-col overflow-hidden'; + if (state.files.length === 1) { + const originalFile = state.files[0]; + showLoader(`Converting ${originalFile.name}...`); - const nameSpan = document.createElement('div'); - nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1'; - nameSpan.textContent = file.name; + const pdfBlob = await pymupdf.convertToPdf(originalFile, { + filetype: FILETYPE, + }); + const fileName = originalFile.name.replace(/\.[^.]+$/, '') + '.pdf'; - const metaSpan = document.createElement('div'); - metaSpan.className = 'text-xs text-gray-400'; - metaSpan.textContent = formatBytes(file.size); + downloadFile(pdfBlob, fileName); + hideLoader(); - infoContainer.append(nameSpan, metaSpan); + showAlert( + 'Conversion Complete', + `Successfully converted ${originalFile.name} to PDF.`, + 'success', + () => resetState() + ); + } else { + showLoader('Converting files...'); + const JSZip = (await import('jszip')).default; + const zip = new JSZip(); - const removeBtn = document.createElement('button'); - removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0'; - removeBtn.innerHTML = ''; - removeBtn.onclick = () => { - state.files = state.files.filter((_, i) => i !== index); - updateUI(); - }; + for (let i = 0; i < state.files.length; i++) { + const file = state.files[i]; + showLoader( + `Converting ${i + 1}/${state.files.length}: ${file.name}...` + ); - fileDiv.append(infoContainer, removeBtn); - fileDisplayArea.appendChild(fileDiv); - } - - createIcons({ icons }); - fileControls.classList.remove('hidden'); - processBtn.classList.remove('hidden'); - (processBtn as HTMLButtonElement).disabled = false; - } else { - fileDisplayArea.innerHTML = ''; - fileControls.classList.add('hidden'); - processBtn.classList.add('hidden'); - (processBtn as HTMLButtonElement).disabled = true; + const pdfBlob = await pymupdf.convertToPdf(file, { + filetype: FILETYPE, + }); + const baseName = file.name.replace(/\.[^.]+$/, ''); + const pdfBuffer = await pdfBlob.arrayBuffer(); + zip.file(`${baseName}.pdf`, pdfBuffer); } - }; - const resetState = () => { - state.files = []; - state.pdfDoc = null; - updateUI(); - }; + const zipBlob = await zip.generateAsync({ type: 'blob' }); + downloadFile(zipBlob, `${FILETYPE}-converted.zip`); - const convertToPdf = async () => { - try { - if (state.files.length === 0) { - showAlert('No Files', `Please select at least one ${TOOL_NAME} file.`); - return; - } + hideLoader(); - showLoader('Loading engine...'); - const pymupdf = new PyMuPDF(getWasmBaseUrl('pymupdf')); - await pymupdf.load(); + showAlert( + 'Conversion Complete', + `Successfully converted ${state.files.length} ${TOOL_NAME} file(s) to PDF.`, + 'success', + () => resetState() + ); + } + } catch (e: any) { + console.error(`[${TOOL_NAME}2PDF] ERROR:`, e); + hideLoader(); + showAlert( + 'Error', + `An error occurred during conversion. Error: ${e.message}` + ); + } + }; - if (state.files.length === 1) { - const originalFile = state.files[0]; - showLoader(`Converting ${originalFile.name}...`); + const handleFileSelect = (files: FileList | null) => { + if (files && files.length > 0) { + state.files = [...state.files, ...Array.from(files)]; + updateUI(); + } + }; - const pdfBlob = await pymupdf.convertToPdf(originalFile, { filetype: FILETYPE }); - const fileName = originalFile.name.replace(/\.[^.]+$/, '') + '.pdf'; + if (fileInput && dropZone) { + fileInput.addEventListener('change', (e) => { + handleFileSelect((e.target as HTMLInputElement).files); + }); - downloadFile(pdfBlob, fileName); - hideLoader(); + dropZone.addEventListener('dragover', (e) => { + e.preventDefault(); + dropZone.classList.add('bg-gray-700'); + }); - showAlert( - 'Conversion Complete', - `Successfully converted ${originalFile.name} to PDF.`, - 'success', - () => resetState() - ); - } else { - showLoader('Converting files...'); - const JSZip = (await import('jszip')).default; - const zip = new JSZip(); + dropZone.addEventListener('dragleave', (e) => { + e.preventDefault(); + dropZone.classList.remove('bg-gray-700'); + }); - for (let i = 0; i < state.files.length; i++) { - const file = state.files[i]; - showLoader(`Converting ${i + 1}/${state.files.length}: ${file.name}...`); - - const pdfBlob = await pymupdf.convertToPdf(file, { filetype: FILETYPE }); - const baseName = file.name.replace(/\.[^.]+$/, ''); - const pdfBuffer = await pdfBlob.arrayBuffer(); - zip.file(`${baseName}.pdf`, pdfBuffer); - } - - const zipBlob = await zip.generateAsync({ type: 'blob' }); - downloadFile(zipBlob, `${FILETYPE}-converted.zip`); - - hideLoader(); - - showAlert( - 'Conversion Complete', - `Successfully converted ${state.files.length} ${TOOL_NAME} file(s) to PDF.`, - 'success', - () => resetState() - ); - } - } catch (e: any) { - console.error(`[${TOOL_NAME}2PDF] ERROR:`, e); - hideLoader(); - showAlert('Error', `An error occurred during conversion. Error: ${e.message}`); + dropZone.addEventListener('drop', (e) => { + e.preventDefault(); + dropZone.classList.remove('bg-gray-700'); + const files = e.dataTransfer?.files; + if (files && files.length > 0) { + const validFiles = Array.from(files).filter((f) => { + const name = f.name.toLowerCase(); + return EXTENSIONS.some((ext) => name.endsWith(ext)); + }); + if (validFiles.length > 0) { + const dataTransfer = new DataTransfer(); + validFiles.forEach((f) => dataTransfer.items.add(f)); + handleFileSelect(dataTransfer.files); } - }; + } + }); - const handleFileSelect = (files: FileList | null) => { - if (files && files.length > 0) { - state.files = [...state.files, ...Array.from(files)]; - updateUI(); - } - }; + fileInput.addEventListener('click', () => { + fileInput.value = ''; + }); + } - if (fileInput && dropZone) { - fileInput.addEventListener('change', (e) => { - handleFileSelect((e.target as HTMLInputElement).files); - }); + if (addMoreBtn) { + addMoreBtn.addEventListener('click', () => { + fileInput.click(); + }); + } - dropZone.addEventListener('dragover', (e) => { - e.preventDefault(); - dropZone.classList.add('bg-gray-700'); - }); + if (clearFilesBtn) { + clearFilesBtn.addEventListener('click', () => { + resetState(); + }); + } - dropZone.addEventListener('dragleave', (e) => { - e.preventDefault(); - dropZone.classList.remove('bg-gray-700'); - }); - - dropZone.addEventListener('drop', (e) => { - e.preventDefault(); - dropZone.classList.remove('bg-gray-700'); - const files = e.dataTransfer?.files; - if (files && files.length > 0) { - const validFiles = Array.from(files).filter(f => { - const name = f.name.toLowerCase(); - return EXTENSIONS.some(ext => name.endsWith(ext)); - }); - if (validFiles.length > 0) { - const dataTransfer = new DataTransfer(); - validFiles.forEach(f => dataTransfer.items.add(f)); - handleFileSelect(dataTransfer.files); - } - } - }); - - fileInput.addEventListener('click', () => { - fileInput.value = ''; - }); - } - - if (addMoreBtn) { - addMoreBtn.addEventListener('click', () => { - fileInput.click(); - }); - } - - if (clearFilesBtn) { - clearFilesBtn.addEventListener('click', () => { - resetState(); - }); - } - - if (processBtn) { - processBtn.addEventListener('click', convertToPdf); - } + if (processBtn) { + processBtn.addEventListener('click', convertToPdf); + } }); diff --git a/src/js/logic/ocr-pdf-page.ts b/src/js/logic/ocr-pdf-page.ts index 422ae66..1f8318c 100644 --- a/src/js/logic/ocr-pdf-page.ts +++ b/src/js/logic/ocr-pdf-page.ts @@ -1,28 +1,14 @@ import { tesseractLanguages } from '../config/tesseract-languages.js'; import { showAlert } from '../ui.js'; -import { downloadFile, formatBytes, getPDFDocument } from '../utils/helpers.js'; -import Tesseract from 'tesseract.js'; -import { - PDFDocument as PDFLibDocument, - StandardFonts, - rgb, - PDFFont, -} from 'pdf-lib'; -import fontkit from '@pdf-lib/fontkit'; +import { downloadFile, formatBytes } from '../utils/helpers.js'; import { icons, createIcons } from 'lucide'; -import * as pdfjsLib from 'pdfjs-dist'; -import { getFontForLanguage } from '../utils/font-loader.js'; -import { OcrState, OcrLine, OcrPage } from '@/types'; +import { OcrState } from '@/types'; +import { performOcr } from '../utils/ocr.js'; import { - parseHocrDocument, - calculateWordTransform, - calculateSpaceTransform, -} from '../utils/hocr-transform.js'; - -pdfjsLib.GlobalWorkerOptions.workerSrc = new URL( - 'pdfjs-dist/build/pdf.worker.min.mjs', - import.meta.url -).toString(); + getAvailableTesseractLanguageEntries, + resolveConfiguredTesseractAvailableLanguages, + UnsupportedOcrLanguageError, +} from '../utils/tesseract-language-availability.js'; const pageState: OcrState = { file: null, @@ -40,108 +26,6 @@ const whitelistPresets: Record = { 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789 .,()-_/@#:', }; -function drawOcrTextLayer( - page: ReturnType, - ocrPage: OcrPage, - pageHeight: number, - primaryFont: PDFFont, - latinFont: PDFFont -): void { - ocrPage.lines.forEach(function (line: OcrLine) { - const words = line.words; - - for (let i = 0; i < words.length; i++) { - const word = words[i]; - const text = word.text.replace( - /[\u0000-\u001F\u007F-\u009F\u200E\u200F\u202A-\u202E\uFEFF]/g, - '' - ); - - if (!text.trim()) continue; - - const hasNonLatin = /[^\u0000-\u007F]/.test(text); - const font = hasNonLatin ? primaryFont : latinFont; - - if (!font) { - console.warn('Font not available for text: "' + text + '"'); - continue; - } - - const transform = calculateWordTransform( - word, - line, - pageHeight, - (txt: string, size: number) => { - try { - return font.widthOfTextAtSize(txt, size); - } catch { - return 0; - } - } - ); - - if (transform.fontSize <= 0) continue; - - try { - page.drawText(text, { - x: transform.x, - y: transform.y, - font, - size: transform.fontSize, - color: rgb(0, 0, 0), - opacity: 0, - }); - } catch (error) { - console.warn(`Could not draw text "${text}":`, error); - } - - if (line.injectWordBreaks && i < words.length - 1) { - const nextWord = words[i + 1]; - const spaceTransform = calculateSpaceTransform( - word, - nextWord, - line, - pageHeight, - (size: number) => { - try { - return font.widthOfTextAtSize(' ', size); - } catch { - return 0; - } - } - ); - - if (spaceTransform && spaceTransform.horizontalScale > 0.1) { - try { - page.drawText(' ', { - x: spaceTransform.x, - y: spaceTransform.y, - font, - size: spaceTransform.fontSize, - color: rgb(0, 0, 0), - opacity: 0, - }); - } catch { - console.warn(`Could not draw space between words`); - } - } - } - } - }); -} - -function binarizeCanvas(ctx: CanvasRenderingContext2D) { - const imageData = ctx.getImageData(0, 0, ctx.canvas.width, ctx.canvas.height); - const data = imageData.data; - for (let i = 0; i < data.length; i += 4) { - const brightness = - 0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2]; - const color = brightness > 128 ? 255 : 0; - data[i] = data[i + 1] = data[i + 2] = color; - } - ctx.putImageData(imageData, 0, 0); -} - function updateProgress(status: string, progress: number) { const progressBar = document.getElementById('progress-bar'); const progressStatus = document.getElementById('progress-status'); @@ -201,6 +85,30 @@ function resetState() { if (processBtn) processBtn.disabled = true; } +function updateLanguageAvailabilityNotice() { + const notice = document.getElementById('lang-availability-note'); + if (!notice) return; + + const configuredLanguages = resolveConfiguredTesseractAvailableLanguages(); + if (!configuredLanguages) { + notice.classList.add('hidden'); + notice.textContent = ''; + return; + } + + const availableEntries = getAvailableTesseractLanguageEntries(); + if (availableEntries.length === 0) { + notice.classList.remove('hidden'); + notice.textContent = + 'This deployment does not expose any valid OCR languages. Rebuild it with VITE_TESSERACT_AVAILABLE_LANGUAGES set to valid Tesseract codes.'; + return; + } + + const availableNames = availableEntries.map(([, name]) => name).join(', '); + notice.classList.remove('hidden'); + notice.textContent = `This deployment bundles OCR for: ${availableNames}.`; +} + async function runOCR() { const selectedLangs = Array.from( document.querySelectorAll('.lang-checkbox:checked') @@ -239,161 +147,17 @@ async function runOCR() { if (ocrProgress) ocrProgress.classList.remove('hidden'); try { - const worker = await Tesseract.createWorker(langString, 1, { - logger: function (m: { status: string; progress: number }) { - updateProgress(m.status, m.progress || 0); - }, - }); - - await worker.setParameters({ - tessjs_create_hocr: '1', - tessedit_pageseg_mode: Tesseract.PSM.AUTO, - }); - - if (whitelist) { - await worker.setParameters({ - tessedit_char_whitelist: whitelist, - }); - } - const arrayBuffer = await pageState.file.arrayBuffer(); - const pdf = await getPDFDocument({ data: arrayBuffer }).promise; - const newPdfDoc = await PDFLibDocument.create(); - newPdfDoc.registerFontkit(fontkit); - - updateProgress('Loading fonts...', 0); - - const cjkLangs = ['jpn', 'chi_sim', 'chi_tra', 'kor']; - const indicLangs = [ - 'hin', - 'ben', - 'guj', - 'kan', - 'mal', - 'ori', - 'pan', - 'tam', - 'tel', - 'sin', - ]; - const priorityLangs = [...cjkLangs, ...indicLangs, 'ara', 'rus', 'ukr']; - - const primaryLang = - selectedLangs.find(function (l) { - return priorityLangs.includes(l); - }) || - selectedLangs[0] || - 'eng'; - - const hasCJK = selectedLangs.some(function (l) { - return cjkLangs.includes(l); + const result = await performOcr(new Uint8Array(arrayBuffer), { + language: langString, + resolution: scale, + binarize, + whitelist, + onProgress: updateProgress, }); - const hasIndic = selectedLangs.some(function (l) { - return indicLangs.includes(l); - }); - const hasLatin = - selectedLangs.some(function (l) { - return !priorityLangs.includes(l); - }) || selectedLangs.includes('eng'); - const isIndicPlusLatin = hasIndic && hasLatin && !hasCJK; - let primaryFont; - let latinFont; - - try { - if (isIndicPlusLatin) { - const [scriptFontBytes, latinFontBytes] = await Promise.all([ - getFontForLanguage(primaryLang), - getFontForLanguage('eng'), - ]); - primaryFont = await newPdfDoc.embedFont(scriptFontBytes, { - subset: false, - }); - latinFont = await newPdfDoc.embedFont(latinFontBytes, { - subset: false, - }); - } else { - const fontBytes = await getFontForLanguage(primaryLang); - primaryFont = await newPdfDoc.embedFont(fontBytes, { subset: false }); - latinFont = primaryFont; - } - } catch (e) { - console.error('Font loading failed, falling back to Helvetica', e); - primaryFont = await newPdfDoc.embedFont(StandardFonts.Helvetica); - latinFont = primaryFont; - showAlert( - 'Font Warning', - 'Could not load the specific font for this language. Some characters may not appear correctly.' - ); - } - - let fullText = ''; - - for (let i = 1; i <= pdf.numPages; i++) { - updateProgress( - `Processing page ${i} of ${pdf.numPages}`, - (i - 1) / pdf.numPages - ); - - const page = await pdf.getPage(i); - const viewport = page.getViewport({ scale }); - - const canvas = document.createElement('canvas'); - canvas.width = viewport.width; - canvas.height = viewport.height; - const context = canvas.getContext('2d')!; - - await page.render({ canvasContext: context, viewport, canvas }).promise; - - if (binarize) { - binarizeCanvas(context); - } - - const result = await worker.recognize( - canvas, - {}, - { text: true, hocr: true } - ); - const data = result.data; - - const newPage = newPdfDoc.addPage([viewport.width, viewport.height]); - - const pngImageBytes = await new Promise(function (resolve) { - canvas.toBlob(function (blob) { - const reader = new FileReader(); - reader.onload = function () { - resolve(new Uint8Array(reader.result as ArrayBuffer)); - }; - reader.readAsArrayBuffer(blob!); - }, 'image/png'); - }); - - const pngImage = await newPdfDoc.embedPng(pngImageBytes); - newPage.drawImage(pngImage, { - x: 0, - y: 0, - width: viewport.width, - height: viewport.height, - }); - - if (data.hocr) { - const ocrPage = parseHocrDocument(data.hocr); - drawOcrTextLayer( - newPage, - ocrPage, - viewport.height, - primaryFont, - latinFont - ); - } - - fullText += data.text + '\n\n'; - } - - await worker.terminate(); - - pageState.searchablePdfBytes = await newPdfDoc.save(); + pageState.searchablePdfBytes = result.pdfBytes; const ocrResults = document.getElementById('ocr-results'); if (ocrProgress) ocrProgress.classList.add('hidden'); @@ -404,13 +168,17 @@ async function runOCR() { const textOutput = document.getElementById( 'ocr-text-output' ) as HTMLTextAreaElement; - if (textOutput) textOutput.value = fullText.trim(); + if (textOutput) textOutput.value = result.fullText.trim(); } catch (e) { console.error(e); - showAlert( - 'OCR Error', - 'An error occurred during the OCR process. The worker may have failed to load. Please try again.' - ); + if (e instanceof UnsupportedOcrLanguageError) { + showAlert('OCR Language Not Available', e.message); + } else { + showAlert( + 'OCR Error', + 'An error occurred during the OCR process. The worker may have failed to load. Please try again.' + ); + } if (toolOptions) toolOptions.classList.remove('hidden'); if (ocrProgress) ocrProgress.classList.add('hidden'); } @@ -478,10 +246,21 @@ function populateLanguageList() { langList.innerHTML = ''; - Object.entries(tesseractLanguages).forEach(function ([code, name]) { + const availableEntries = getAvailableTesseractLanguageEntries(); + if (availableEntries.length === 0) { + const emptyState = document.createElement('p'); + emptyState.className = 'text-sm text-yellow-300 p-2'; + emptyState.textContent = + 'No OCR languages are available in this deployment.'; + langList.appendChild(emptyState); + return; + } + + availableEntries.forEach(function ([code, name]) { const label = document.createElement('label'); label.className = 'flex items-center gap-2 p-2 rounded-md hover:bg-gray-700 cursor-pointer'; + label.dataset.search = `${name} ${code}`.toLowerCase(); const checkbox = document.createElement('input'); checkbox.type = 'checkbox'; @@ -518,6 +297,7 @@ document.addEventListener('DOMContentLoaded', function () { const downloadPdfBtn = document.getElementById('download-searchable-pdf'); populateLanguageList(); + updateLanguageAvailabilityNotice(); if (backBtn) { backBtn.addEventListener('click', function () { @@ -569,9 +349,9 @@ document.addEventListener('DOMContentLoaded', function () { langSearch.addEventListener('input', function () { const searchTerm = langSearch.value.toLowerCase(); langList.querySelectorAll('label').forEach(function (label) { - (label as HTMLElement).style.display = label.textContent - ?.toLowerCase() - .includes(searchTerm) + (label as HTMLElement).style.display = ( + label as HTMLElement + ).dataset.search?.includes(searchTerm) ? '' : 'none'; }); diff --git a/src/js/logic/organize-pdf-page.ts b/src/js/logic/organize-pdf-page.ts index 251e0d2..7ca97ff 100644 --- a/src/js/logic/organize-pdf-page.ts +++ b/src/js/logic/organize-pdf-page.ts @@ -1,294 +1,425 @@ import { createIcons, icons } from 'lucide'; import { showAlert, showLoader, hideLoader } from '../ui.js'; -import { readFileAsArrayBuffer, formatBytes, downloadFile, getPDFDocument } from '../utils/helpers.js'; +import { + readFileAsArrayBuffer, + formatBytes, + downloadFile, + getPDFDocument, +} from '../utils/helpers.js'; +import { initPagePreview } from '../utils/page-preview.js'; import { PDFDocument } from 'pdf-lib'; import * as pdfjsLib from 'pdfjs-dist'; import Sortable from 'sortablejs'; -pdfjsLib.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).toString(); +pdfjsLib.GlobalWorkerOptions.workerSrc = new URL( + 'pdfjs-dist/build/pdf.worker.min.mjs', + import.meta.url +).toString(); interface OrganizeState { - file: File | null; - pdfDoc: any; - pdfJsDoc: any; - totalPages: number; - sortableInstance: any; + file: File | null; + pdfDoc: any; + pdfJsDoc: any; + totalPages: number; + sortableInstance: any; } const organizeState: OrganizeState = { - file: null, - pdfDoc: null, - pdfJsDoc: null, - totalPages: 0, - sortableInstance: null, + file: null, + pdfDoc: null, + pdfJsDoc: null, + totalPages: 0, + sortableInstance: null, }; if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', initializePage); + document.addEventListener('DOMContentLoaded', initializePage); } else { - initializePage(); + initializePage(); } function initializePage() { - createIcons({ icons }); + createIcons({ icons }); - const fileInput = document.getElementById('file-input') as HTMLInputElement; - const dropZone = document.getElementById('drop-zone'); - const processBtn = document.getElementById('process-btn'); + const fileInput = document.getElementById('file-input') as HTMLInputElement; + const dropZone = document.getElementById('drop-zone'); + const processBtn = document.getElementById('process-btn'); - if (fileInput) fileInput.addEventListener('change', handleFileUpload); + if (fileInput) fileInput.addEventListener('change', handleFileUpload); - 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]); - }); - // Clear value on click to allow re-selecting the same file - fileInput?.addEventListener('click', () => { - if (fileInput) fileInput.value = ''; - }); - } - - if (processBtn) processBtn.addEventListener('click', saveChanges); - - document.getElementById('back-to-tools')?.addEventListener('click', () => { - window.location.href = import.meta.env.BASE_URL; + 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]); + }); + // Clear value on click to allow re-selecting the same file + fileInput?.addEventListener('click', () => { + if (fileInput) fileInput.value = ''; + }); + } + + if (processBtn) processBtn.addEventListener('click', saveChanges); + + document.getElementById('back-to-tools')?.addEventListener('click', () => { + window.location.href = import.meta.env.BASE_URL; + }); + + const applyOrderBtn = document.getElementById('apply-order-btn'); + if (applyOrderBtn) applyOrderBtn.addEventListener('click', applyCustomOrder); +} + +function applyCustomOrder() { + const orderInput = document.getElementById( + 'page-order-input' + ) as HTMLInputElement; + const grid = document.getElementById('page-grid'); + + if (!orderInput || !grid) return; + + const orderString = orderInput.value; + if (!orderString) { + showAlert('Invalid Order', 'Please enter a page order.'); + return; + } + + const newOrder = orderString.split(',').map((s) => parseInt(s.trim(), 10)); + + // Validation + const currentGridCount = grid.children.length; + const validNumbers = newOrder.every((n) => !isNaN(n) && n > 0); // Basic check, will validate against available thumbnails + if (!validNumbers) { + showAlert('Invalid Page Numbers', `Please enter positive numbers.`); + return; + } + + if (newOrder.length !== currentGridCount) { + showAlert( + 'Incorrect Page Count', + `The number of pages specified (${newOrder.length}) does not match the current number of pages in the document (${currentGridCount}). Please provide a complete ordering for all pages.` + ); + return; + } + + const uniqueNumbers = new Set(newOrder); + if (uniqueNumbers.size !== newOrder.length) { + showAlert( + 'Duplicate Page Numbers', + 'Please ensure all page numbers in the order are unique.' + ); + return; + } + + const currentThumbnails = Array.from(grid.children) as HTMLElement[]; + const reorderedThumbnails: HTMLElement[] = []; + const foundIndices = new Set(); + + for (const pageNum of newOrder) { + const originalIndexToFind = pageNum - 1; // pageNum is 1-based, originalPageIndex is 0-based + const foundThumbnail = currentThumbnails.find( + (thumb) => + thumb.dataset.originalPageIndex === originalIndexToFind.toString() + ); + + if (foundThumbnail) { + reorderedThumbnails.push(foundThumbnail); + foundIndices.add(originalIndexToFind.toString()); + } + } + + const allOriginalIndicesPresent = currentThumbnails.every((thumb) => + foundIndices.has(thumb.dataset.originalPageIndex) + ); + + if ( + reorderedThumbnails.length !== currentGridCount || + !allOriginalIndicesPresent + ) { + showAlert( + 'Invalid Page Order', + 'The specified page order is incomplete or contains invalid page numbers. Please ensure you provide a new position for every original page.' + ); + return; + } + + // Clear the grid and append the reordered thumbnails + grid.innerHTML = ''; + reorderedThumbnails.forEach((thumb) => grid.appendChild(thumb)); + + initializeSortable(); // Re-initialize sortable on the new order + + showAlert('Success', 'Pages have been reordered.', 'success'); } function handleFileUpload(e: Event) { - const input = e.target as HTMLInputElement; - if (input.files && input.files.length > 0) handleFile(input.files[0]); + const input = e.target as HTMLInputElement; + if (input.files && input.files.length > 0) handleFile(input.files[0]); } async function handleFile(file: File) { - if (file.type !== 'application/pdf' && !file.name.toLowerCase().endsWith('.pdf')) { - showAlert('Invalid File', 'Please select a PDF file.'); - return; - } + if ( + file.type !== 'application/pdf' && + !file.name.toLowerCase().endsWith('.pdf') + ) { + showAlert('Invalid File', 'Please select a PDF file.'); + return; + } - showLoader('Loading PDF...'); - organizeState.file = file; + showLoader('Loading PDF...'); + organizeState.file = file; - try { - const arrayBuffer = await readFileAsArrayBuffer(file); - organizeState.pdfDoc = await PDFDocument.load(arrayBuffer as ArrayBuffer, { ignoreEncryption: true, throwOnInvalidObject: false }); - organizeState.pdfJsDoc = await getPDFDocument({ data: (arrayBuffer as ArrayBuffer).slice(0) }).promise; - organizeState.totalPages = organizeState.pdfDoc.getPageCount(); + try { + const arrayBuffer = await readFileAsArrayBuffer(file); + organizeState.pdfDoc = await PDFDocument.load(arrayBuffer as ArrayBuffer, { + ignoreEncryption: true, + throwOnInvalidObject: false, + }); + organizeState.pdfJsDoc = await getPDFDocument({ + data: (arrayBuffer as ArrayBuffer).slice(0), + }).promise; + organizeState.totalPages = organizeState.pdfDoc.getPageCount(); - updateFileDisplay(); - await renderThumbnails(); - hideLoader(); - } catch (error) { - console.error('Error loading PDF:', error); - hideLoader(); - showAlert('Error', 'Failed to load PDF file.'); - } + updateFileDisplay(); + await renderThumbnails(); + hideLoader(); + } catch (error) { + console.error('Error loading PDF:', error); + hideLoader(); + showAlert('Error', 'Failed to load PDF file.'); + } } function updateFileDisplay() { - const fileDisplayArea = document.getElementById('file-display-area'); - if (!fileDisplayArea || !organizeState.file) return; + const fileDisplayArea = document.getElementById('file-display-area'); + if (!fileDisplayArea || !organizeState.file) return; - fileDisplayArea.innerHTML = ''; - const fileDiv = document.createElement('div'); - fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg'; + 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 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 = organizeState.file.name; + const nameSpan = document.createElement('div'); + nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1'; + nameSpan.textContent = organizeState.file.name; - const metaSpan = document.createElement('div'); - metaSpan.className = 'text-xs text-gray-400'; - metaSpan.textContent = `${formatBytes(organizeState.file.size)} • ${organizeState.totalPages} pages`; + const metaSpan = document.createElement('div'); + metaSpan.className = 'text-xs text-gray-400'; + metaSpan.textContent = `${formatBytes(organizeState.file.size)} • ${organizeState.totalPages} pages`; - infoContainer.append(nameSpan, metaSpan); + infoContainer.append(nameSpan, metaSpan); - const removeBtn = document.createElement('button'); - removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0'; - removeBtn.innerHTML = ''; - removeBtn.onclick = () => resetState(); + 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 }); + fileDiv.append(infoContainer, removeBtn); + fileDisplayArea.appendChild(fileDiv); + createIcons({ icons }); } function renumberPages() { - const grid = document.getElementById('page-grid'); - if (!grid) return; - const labels = grid.querySelectorAll('.page-number'); - labels.forEach((label, index) => { - label.textContent = (index + 1).toString(); - }); + const grid = document.getElementById('page-grid'); + if (!grid) return; + const labels = grid.querySelectorAll('.page-number'); + labels.forEach((label, index) => { + label.textContent = (index + 1).toString(); + }); } function attachEventListeners(element: HTMLElement) { - const duplicateBtn = element.querySelector('.duplicate-btn'); - const deleteBtn = element.querySelector('.delete-btn'); + const duplicateBtn = element.querySelector('.duplicate-btn'); + const deleteBtn = element.querySelector('.delete-btn'); - duplicateBtn?.addEventListener('click', (e) => { - e.stopPropagation(); - const clone = element.cloneNode(true) as HTMLElement; - element.after(clone); - attachEventListeners(clone); - renumberPages(); - createIcons({ icons }); - initializeSortable(); - }); + duplicateBtn?.addEventListener('click', (e) => { + e.stopPropagation(); + const clone = element.cloneNode(true) as HTMLElement; + element.after(clone); + attachEventListeners(clone); + renumberPages(); + createIcons({ icons }); + initializeSortable(); + }); - deleteBtn?.addEventListener('click', (e) => { - e.stopPropagation(); - const grid = document.getElementById('page-grid'); - if (grid && grid.children.length > 1) { - element.remove(); - renumberPages(); - initializeSortable(); - } else { - showAlert('Cannot Delete', 'You cannot delete the last page of the document.'); - } - }); + deleteBtn?.addEventListener('click', (e) => { + e.stopPropagation(); + const grid = document.getElementById('page-grid'); + if (grid && grid.children.length > 1) { + element.remove(); + renumberPages(); + initializeSortable(); + } else { + showAlert( + 'Cannot Delete', + 'You cannot delete the last page of the document.' + ); + } + }); } async function renderThumbnails() { - const grid = document.getElementById('page-grid'); - const processBtn = document.getElementById('process-btn'); - if (!grid) return; + const grid = document.getElementById('page-grid'); + const processBtn = document.getElementById('process-btn'); + const advancedSettings = document.getElementById('advanced-settings'); + if (!grid || !processBtn || !advancedSettings) return; - grid.innerHTML = ''; - grid.classList.remove('hidden'); - processBtn?.classList.remove('hidden'); + grid.innerHTML = ''; + grid.classList.remove('hidden'); + processBtn.classList.remove('hidden'); + advancedSettings.classList.remove('hidden'); - for (let i = 1; i <= organizeState.totalPages; i++) { - const page = await organizeState.pdfJsDoc.getPage(i); - const viewport = page.getViewport({ scale: 0.5 }); + for (let i = 1; i <= organizeState.totalPages; i++) { + const page = await organizeState.pdfJsDoc.getPage(i); + const viewport = page.getViewport({ scale: 1 }); - const canvas = document.createElement('canvas'); - canvas.width = viewport.width; - canvas.height = viewport.height; - const ctx = canvas.getContext('2d'); - await page.render({ canvasContext: ctx, viewport }).promise; + const canvas = document.createElement('canvas'); + canvas.width = viewport.width; + canvas.height = viewport.height; + const ctx = canvas.getContext('2d'); + await page.render({ canvasContext: ctx, viewport }).promise; - const wrapper = document.createElement('div'); - wrapper.className = 'page-thumbnail relative cursor-move flex flex-col items-center gap-2'; - wrapper.dataset.originalPageIndex = (i - 1).toString(); + const wrapper = document.createElement('div'); + wrapper.className = + 'page-thumbnail relative cursor-move flex flex-col items-center gap-1 p-2 border-2 border-gray-600 hover:border-indigo-500 rounded-lg bg-gray-700 transition-colors group'; + wrapper.dataset.originalPageIndex = (i - 1).toString(); + wrapper.dataset.pageNumber = i.toString(); - const imgContainer = document.createElement('div'); - imgContainer.className = 'w-full h-36 bg-gray-900 rounded-lg flex items-center justify-center overflow-hidden border-2 border-gray-600'; + const imgContainer = document.createElement('div'); + imgContainer.className = 'relative'; - const img = document.createElement('img'); - img.src = canvas.toDataURL(); - img.className = 'max-w-full max-h-full object-contain'; - imgContainer.appendChild(img); + const img = document.createElement('img'); + img.src = canvas.toDataURL(); + img.className = 'rounded-md shadow-md max-w-full h-auto'; + imgContainer.appendChild(img); - const pageLabel = document.createElement('span'); - pageLabel.className = 'page-number absolute top-1 left-1 bg-gray-900 bg-opacity-75 text-white text-xs rounded-full px-2 py-1'; - pageLabel.textContent = i.toString(); + const pageLabel = document.createElement('div'); + pageLabel.className = + 'page-number absolute top-1 left-1 bg-indigo-600 text-white text-xs px-2 py-1 rounded-md font-semibold shadow-lg z-10 pointer-events-none'; + pageLabel.textContent = i.toString(); + imgContainer.appendChild(pageLabel); - const controlsDiv = document.createElement('div'); - controlsDiv.className = 'flex items-center justify-center gap-4'; + const controlsDiv = document.createElement('div'); + controlsDiv.className = 'flex items-center justify-center gap-4'; - const duplicateBtn = document.createElement('button'); - duplicateBtn.className = 'duplicate-btn bg-green-600 hover:bg-green-700 text-white rounded-full w-8 h-8 flex items-center justify-center'; - duplicateBtn.title = 'Duplicate Page'; - duplicateBtn.innerHTML = ''; + const duplicateBtn = document.createElement('button'); + duplicateBtn.className = + 'duplicate-btn bg-green-600 hover:bg-green-700 text-white rounded-full w-8 h-8 flex items-center justify-center'; + duplicateBtn.title = 'Duplicate Page'; + duplicateBtn.innerHTML = ''; - const deleteBtn = document.createElement('button'); - deleteBtn.className = 'delete-btn bg-red-600 hover:bg-red-700 text-white rounded-full w-8 h-8 flex items-center justify-center'; - deleteBtn.title = 'Delete Page'; - deleteBtn.innerHTML = ''; + const deleteBtn = document.createElement('button'); + deleteBtn.className = + 'delete-btn bg-red-600 hover:bg-red-700 text-white rounded-full w-8 h-8 flex items-center justify-center'; + deleteBtn.title = 'Delete Page'; + deleteBtn.innerHTML = ''; - controlsDiv.append(duplicateBtn, deleteBtn); - wrapper.append(imgContainer, pageLabel, controlsDiv); - grid.appendChild(wrapper); + controlsDiv.append(duplicateBtn, deleteBtn); + wrapper.append(imgContainer, controlsDiv); + grid.appendChild(wrapper); - attachEventListeners(wrapper); - } + attachEventListeners(wrapper); + } - createIcons({ icons }); - initializeSortable(); + createIcons({ icons }); + initializeSortable(); + initPagePreview(grid, organizeState.pdfJsDoc); } function initializeSortable() { - const grid = document.getElementById('page-grid'); - if (!grid) return; + const grid = document.getElementById('page-grid'); + if (!grid) return; - if (organizeState.sortableInstance) organizeState.sortableInstance.destroy(); + if (organizeState.sortableInstance) organizeState.sortableInstance.destroy(); - organizeState.sortableInstance = Sortable.create(grid, { - animation: 150, - ghostClass: 'sortable-ghost', - chosenClass: 'sortable-chosen', - dragClass: 'sortable-drag', - filter: '.duplicate-btn, .delete-btn', - preventOnFilter: true, - onStart: (evt) => { - if (evt.item) evt.item.style.opacity = '0.5'; - }, - onEnd: (evt) => { - if (evt.item) evt.item.style.opacity = '1'; - }, - }); + organizeState.sortableInstance = Sortable.create(grid, { + animation: 150, + ghostClass: 'sortable-ghost', + chosenClass: 'sortable-chosen', + dragClass: 'sortable-drag', + filter: '.duplicate-btn, .delete-btn', + preventOnFilter: true, + onStart: (evt) => { + if (evt.item) evt.item.style.opacity = '0.5'; + }, + onEnd: (evt) => { + if (evt.item) evt.item.style.opacity = '1'; + }, + }); } async function saveChanges() { - showLoader('Building new PDF...'); + showLoader('Building new PDF...'); - try { - const grid = document.getElementById('page-grid'); - if (!grid) return; + try { + const grid = document.getElementById('page-grid'); + if (!grid) return; - const finalPageElements = grid.querySelectorAll('.page-thumbnail'); - const finalIndices = Array.from(finalPageElements) - .map(el => parseInt((el as HTMLElement).dataset.originalPageIndex || '', 10)) - .filter(index => !isNaN(index) && index >= 0); + const finalPageElements = grid.querySelectorAll('.page-thumbnail'); + const finalIndices = Array.from(finalPageElements) + .map((el) => + parseInt((el as HTMLElement).dataset.originalPageIndex || '', 10) + ) + .filter((index) => !isNaN(index) && index >= 0); - if (finalIndices.length === 0) { - showAlert('Error', 'No valid pages to save.'); - return; - } - - const newPdf = await PDFDocument.create(); - const copiedPages = await newPdf.copyPages(organizeState.pdfDoc, finalIndices); - copiedPages.forEach(page => newPdf.addPage(page)); - - const pdfBytes = await newPdf.save(); - const baseName = organizeState.file?.name.replace('.pdf', '') || 'document'; - downloadFile(new Blob([pdfBytes as BlobPart], { type: 'application/pdf' }), `${baseName}_organized.pdf`); - - hideLoader(); - showAlert('Success', 'PDF organized successfully!', 'success', () => resetState()); - } catch (error) { - console.error('Error saving changes:', error); - hideLoader(); - showAlert('Error', 'Failed to save changes.'); + if (finalIndices.length === 0) { + showAlert('Error', 'No valid pages to save.'); + return; } + + const newPdf = await PDFDocument.create(); + const copiedPages = await newPdf.copyPages( + organizeState.pdfDoc, + finalIndices + ); + copiedPages.forEach((page) => newPdf.addPage(page)); + + const pdfBytes = await newPdf.save(); + const baseName = organizeState.file?.name.replace('.pdf', '') || 'document'; + downloadFile( + new Blob([pdfBytes as BlobPart], { type: 'application/pdf' }), + `${baseName}_organized.pdf` + ); + + hideLoader(); + showAlert('Success', 'PDF organized successfully!', 'success', () => + resetState() + ); + } catch (error) { + console.error('Error saving changes:', error); + hideLoader(); + showAlert('Error', 'Failed to save changes.'); + } } function resetState() { - if (organizeState.sortableInstance) { - organizeState.sortableInstance.destroy(); - organizeState.sortableInstance = null; - } + if (organizeState.sortableInstance) { + organizeState.sortableInstance.destroy(); + organizeState.sortableInstance = null; + } - organizeState.file = null; - organizeState.pdfDoc = null; - organizeState.pdfJsDoc = null; - organizeState.totalPages = 0; + organizeState.file = null; + organizeState.pdfDoc = null; + organizeState.pdfJsDoc = null; + organizeState.totalPages = 0; - const grid = document.getElementById('page-grid'); - if (grid) { - grid.innerHTML = ''; - grid.classList.add('hidden'); - } - document.getElementById('process-btn')?.classList.add('hidden'); - const fileDisplayArea = document.getElementById('file-display-area'); - if (fileDisplayArea) fileDisplayArea.innerHTML = ''; + const grid = document.getElementById('page-grid'); + if (grid) { + grid.innerHTML = ''; + grid.classList.add('hidden'); + } + document.getElementById('process-btn')?.classList.add('hidden'); + document.getElementById('advanced-settings')?.classList.add('hidden'); + const fileDisplayArea = document.getElementById('file-display-area'); + if (fileDisplayArea) fileDisplayArea.innerHTML = ''; } diff --git a/src/js/logic/page-numbers-page.ts b/src/js/logic/page-numbers-page.ts index 12f3d53..3971814 100644 --- a/src/js/logic/page-numbers-page.ts +++ b/src/js/logic/page-numbers-page.ts @@ -1,230 +1,190 @@ import { createIcons, icons } from 'lucide'; import { showAlert, showLoader, hideLoader } from '../ui.js'; import { downloadFile, hexToRgb, formatBytes } from '../utils/helpers.js'; -import { PDFDocument as PDFLibDocument, rgb, StandardFonts } from 'pdf-lib'; +import { PDFDocument as PDFLibDocument } from 'pdf-lib'; +import { + addPageNumbers as addPageNumbersToPdf, + type PageNumberPosition, + type PageNumberFormat, +} from '../utils/pdf-operations.js'; interface PageState { - file: File | null; - pdfDoc: PDFLibDocument | null; + file: File | null; + pdfDoc: PDFLibDocument | null; } const pageState: PageState = { - file: null, - pdfDoc: null, + file: null, + pdfDoc: null, }; if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', initializePage); + document.addEventListener('DOMContentLoaded', initializePage); } else { - initializePage(); + initializePage(); } function initializePage() { - createIcons({ icons }); + createIcons({ icons }); - const fileInput = document.getElementById('file-input') as HTMLInputElement; - const dropZone = document.getElementById('drop-zone'); - const backBtn = document.getElementById('back-to-tools'); - const processBtn = document.getElementById('process-btn'); + const fileInput = document.getElementById('file-input') as HTMLInputElement; + const dropZone = document.getElementById('drop-zone'); + const backBtn = document.getElementById('back-to-tools'); + const processBtn = document.getElementById('process-btn'); - if (fileInput) { - fileInput.addEventListener('change', handleFileUpload); - fileInput.addEventListener('click', () => { fileInput.value = ''; }); - } + if (fileInput) { + fileInput.addEventListener('change', handleFileUpload); + fileInput.addEventListener('click', () => { + fileInput.value = ''; + }); + } - if (dropZone) { - dropZone.addEventListener('dragover', (e) => { - e.preventDefault(); - dropZone.classList.add('border-indigo-500'); - }); + 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('dragleave', () => { + dropZone.classList.remove('border-indigo-500'); + }); - dropZone.addEventListener('drop', (e) => { - e.preventDefault(); - dropZone.classList.remove('border-indigo-500'); - if (e.dataTransfer?.files.length) { - handleFiles(e.dataTransfer.files); - } - }); - } + dropZone.addEventListener('drop', (e) => { + e.preventDefault(); + dropZone.classList.remove('border-indigo-500'); + if (e.dataTransfer?.files.length) { + handleFiles(e.dataTransfer.files); + } + }); + } - if (backBtn) { - backBtn.addEventListener('click', () => { - window.location.href = import.meta.env.BASE_URL; - }); - } + if (backBtn) { + backBtn.addEventListener('click', () => { + window.location.href = import.meta.env.BASE_URL; + }); + } - if (processBtn) { - processBtn.addEventListener('click', addPageNumbers); - } + if (processBtn) { + processBtn.addEventListener('click', addPageNumbers); + } } function handleFileUpload(e: Event) { - const input = e.target as HTMLInputElement; - if (input.files?.length) { - handleFiles(input.files); - } + const input = e.target as HTMLInputElement; + if (input.files?.length) { + handleFiles(input.files); + } } async function handleFiles(files: FileList) { - const file = files[0]; - if (!file || file.type !== 'application/pdf') { - showAlert('Invalid File', 'Please upload a valid PDF file.'); - return; - } + const file = files[0]; + if (!file || file.type !== 'application/pdf') { + showAlert('Invalid File', 'Please upload a valid PDF file.'); + return; + } - showLoader('Loading PDF...'); - try { - const arrayBuffer = await file.arrayBuffer(); - pageState.pdfDoc = await PDFLibDocument.load(arrayBuffer); - pageState.file = file; + showLoader('Loading PDF...'); + try { + const arrayBuffer = await file.arrayBuffer(); + pageState.pdfDoc = await PDFLibDocument.load(arrayBuffer); + pageState.file = file; - updateFileDisplay(); - document.getElementById('options-panel')?.classList.remove('hidden'); - } catch (error) { - console.error(error); - showAlert('Error', 'Failed to load PDF file.'); - } finally { - hideLoader(); - } + updateFileDisplay(); + document.getElementById('options-panel')?.classList.remove('hidden'); + } catch (error) { + console.error(error); + showAlert('Error', 'Failed to load PDF file.'); + } finally { + hideLoader(); + } } function updateFileDisplay() { - const fileDisplayArea = document.getElementById('file-display-area'); - if (!fileDisplayArea || !pageState.file || !pageState.pdfDoc) return; + const fileDisplayArea = document.getElementById('file-display-area'); + if (!fileDisplayArea || !pageState.file || !pageState.pdfDoc) return; - fileDisplayArea.innerHTML = ''; - const fileDiv = document.createElement('div'); - fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg'; + 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 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 = pageState.file.name; + const nameSpan = document.createElement('div'); + nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1'; + nameSpan.textContent = pageState.file.name; - const metaSpan = document.createElement('div'); - metaSpan.className = 'text-xs text-gray-400'; - metaSpan.textContent = `${formatBytes(pageState.file.size)} • ${pageState.pdfDoc.getPageCount()} pages`; + const metaSpan = document.createElement('div'); + metaSpan.className = 'text-xs text-gray-400'; + metaSpan.textContent = `${formatBytes(pageState.file.size)} • ${pageState.pdfDoc.getPageCount()} pages`; - infoContainer.append(nameSpan, metaSpan); + infoContainer.append(nameSpan, metaSpan); - const removeBtn = document.createElement('button'); - removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0'; - removeBtn.innerHTML = ''; - removeBtn.onclick = resetState; + 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 }); + fileDiv.append(infoContainer, removeBtn); + fileDisplayArea.appendChild(fileDiv); + createIcons({ icons }); } function resetState() { - pageState.file = null; - pageState.pdfDoc = null; - const fileDisplayArea = document.getElementById('file-display-area'); - if (fileDisplayArea) fileDisplayArea.innerHTML = ''; - document.getElementById('options-panel')?.classList.add('hidden'); - const fileInput = document.getElementById('file-input') as HTMLInputElement; - if (fileInput) fileInput.value = ''; + pageState.file = null; + pageState.pdfDoc = null; + const fileDisplayArea = document.getElementById('file-display-area'); + if (fileDisplayArea) fileDisplayArea.innerHTML = ''; + document.getElementById('options-panel')?.classList.add('hidden'); + const fileInput = document.getElementById('file-input') as HTMLInputElement; + if (fileInput) fileInput.value = ''; } async function addPageNumbers() { - if (!pageState.pdfDoc) { - showAlert('Error', 'Please upload a PDF file first.'); - return; - } + if (!pageState.pdfDoc) { + showAlert('Error', 'Please upload a PDF file first.'); + return; + } - showLoader('Adding page numbers...'); - try { - const position = (document.getElementById('position') as HTMLSelectElement).value; - const fontSize = parseInt((document.getElementById('font-size') as HTMLInputElement).value) || 12; - const format = (document.getElementById('number-format') as HTMLSelectElement).value; - const colorHex = (document.getElementById('text-color') as HTMLInputElement).value; - const textColor = hexToRgb(colorHex); + showLoader('Adding page numbers...'); + try { + const position = (document.getElementById('position') as HTMLSelectElement) + .value as PageNumberPosition; + const fontSize = + parseInt( + (document.getElementById('font-size') as HTMLInputElement).value + ) || 12; + const format = + (document.getElementById('number-format') as HTMLSelectElement).value === + 'page_x_of_y' + ? ('page_x_of_y' as PageNumberFormat) + : ('simple' as PageNumberFormat); + const colorHex = (document.getElementById('text-color') as HTMLInputElement) + .value; + const textColor = hexToRgb(colorHex); - const pages = pageState.pdfDoc.getPages(); - const totalPages = pages.length; - const helveticaFont = await pageState.pdfDoc.embedFont(StandardFonts.Helvetica); + const pdfBytes = new Uint8Array(await pageState.pdfDoc.save()); + const resultBytes = await addPageNumbersToPdf(pdfBytes, { + position, + fontSize, + format, + color: textColor, + }); - for (let i = 0; i < totalPages; i++) { - const page = pages[i]; - - const mediaBox = page.getMediaBox(); - const cropBox = page.getCropBox(); - const bounds = cropBox || mediaBox; - const width = bounds.width; - const height = bounds.height; - const xOffset = bounds.x || 0; - const yOffset = bounds.y || 0; - - const pageNumText = format === 'page_x_of_y' ? `${i + 1} / ${totalPages}` : `${i + 1}`; - - const textWidth = helveticaFont.widthOfTextAtSize(pageNumText, fontSize); - const textHeight = fontSize; - - const minMargin = 8; - const maxMargin = 40; - const marginPercentage = 0.04; - - const horizontalMargin = Math.max(minMargin, Math.min(maxMargin, width * marginPercentage)); - const verticalMargin = Math.max(minMargin, Math.min(maxMargin, height * marginPercentage)); - - const safeHorizontalMargin = Math.max(horizontalMargin, textWidth / 2 + 3); - const safeVerticalMargin = Math.max(verticalMargin, textHeight + 3); - - let x = 0, y = 0; - - switch (position) { - case 'bottom-center': - x = Math.max(safeHorizontalMargin, Math.min(width - safeHorizontalMargin - textWidth, (width - textWidth) / 2)) + xOffset; - y = safeVerticalMargin + yOffset; - break; - case 'bottom-left': - x = safeHorizontalMargin + xOffset; - y = safeVerticalMargin + yOffset; - break; - case 'bottom-right': - x = Math.max(safeHorizontalMargin, width - safeHorizontalMargin - textWidth) + xOffset; - y = safeVerticalMargin + yOffset; - break; - case 'top-center': - x = Math.max(safeHorizontalMargin, Math.min(width - safeHorizontalMargin - textWidth, (width - textWidth) / 2)) + xOffset; - y = height - safeVerticalMargin - textHeight + yOffset; - break; - case 'top-left': - x = safeHorizontalMargin + xOffset; - y = height - safeVerticalMargin - textHeight + yOffset; - break; - case 'top-right': - x = Math.max(safeHorizontalMargin, width - safeHorizontalMargin - textWidth) + xOffset; - y = height - safeVerticalMargin - textHeight + yOffset; - break; - } - - x = Math.max(xOffset + 3, Math.min(xOffset + width - textWidth - 3, x)); - y = Math.max(yOffset + 3, Math.min(yOffset + height - textHeight - 3, y)); - - page.drawText(pageNumText, { - x, - y, - font: helveticaFont, - size: fontSize, - color: rgb(textColor.r, textColor.g, textColor.b), - }); - } - - const newPdfBytes = await pageState.pdfDoc.save(); - downloadFile(new Blob([new Uint8Array(newPdfBytes)], { type: 'application/pdf' }), 'paginated.pdf'); - showAlert('Success', 'Page numbers added successfully!', 'success', () => { resetState(); }); - } catch (e) { - console.error(e); - showAlert('Error', 'Could not add page numbers.'); - } finally { - hideLoader(); - } + downloadFile( + new Blob([resultBytes as unknown as BlobPart], { + type: 'application/pdf', + }), + 'paginated.pdf' + ); + showAlert('Success', 'Page numbers added successfully!', 'success', () => { + resetState(); + }); + } catch (e) { + console.error(e); + showAlert('Error', 'Could not add page numbers.'); + } finally { + hideLoader(); + } } diff --git a/src/js/logic/pdf-booklet-page.ts b/src/js/logic/pdf-booklet-page.ts index 489ba45..0fbca6b 100644 --- a/src/js/logic/pdf-booklet-page.ts +++ b/src/js/logic/pdf-booklet-page.ts @@ -5,513 +5,618 @@ import { PDFDocument as PDFLibDocument, degrees, PageSizes } from 'pdf-lib'; import * as pdfjsLib from 'pdfjs-dist'; pdfjsLib.GlobalWorkerOptions.workerSrc = new URL( - 'pdfjs-dist/build/pdf.worker.mjs', - import.meta.url + 'pdfjs-dist/build/pdf.worker.mjs', + import.meta.url ).toString(); interface BookletState { - file: File | null; - pdfDoc: PDFLibDocument | null; - pdfBytes: Uint8Array | null; - pdfjsDoc: pdfjsLib.PDFDocumentProxy | null; + file: File | null; + pdfDoc: PDFLibDocument | null; + pdfBytes: Uint8Array | null; + pdfjsDoc: pdfjsLib.PDFDocumentProxy | null; } const pageState: BookletState = { - file: null, - pdfDoc: null, - pdfBytes: null, - pdfjsDoc: null, + file: null, + pdfDoc: null, + pdfBytes: null, + pdfjsDoc: null, }; function resetState() { - pageState.file = null; - pageState.pdfDoc = null; - pageState.pdfBytes = null; - pageState.pdfjsDoc = null; + pageState.file = null; + pageState.pdfDoc = null; + pageState.pdfBytes = null; + pageState.pdfjsDoc = null; - const fileDisplayArea = document.getElementById('file-display-area'); - if (fileDisplayArea) fileDisplayArea.innerHTML = ''; + const fileDisplayArea = document.getElementById('file-display-area'); + if (fileDisplayArea) fileDisplayArea.innerHTML = ''; - const toolOptions = document.getElementById('tool-options'); - if (toolOptions) toolOptions.classList.add('hidden'); + const toolOptions = document.getElementById('tool-options'); + if (toolOptions) toolOptions.classList.add('hidden'); - const fileInput = document.getElementById('file-input') as HTMLInputElement; - if (fileInput) fileInput.value = ''; + const fileInput = document.getElementById('file-input') as HTMLInputElement; + if (fileInput) fileInput.value = ''; - const previewArea = document.getElementById('booklet-preview'); - if (previewArea) previewArea.innerHTML = '

Upload a PDF and click "Generate Preview" to see the booklet layout

'; + const previewArea = document.getElementById('booklet-preview'); + if (previewArea) + previewArea.innerHTML = + '

Upload a PDF and click "Generate Preview" to see the booklet layout

'; - const downloadBtn = document.getElementById('download-btn') as HTMLButtonElement; - if (downloadBtn) downloadBtn.disabled = true; + const downloadBtn = document.getElementById( + 'download-btn' + ) as HTMLButtonElement; + if (downloadBtn) downloadBtn.disabled = true; } async function updateUI() { - const fileDisplayArea = document.getElementById('file-display-area'); - const toolOptions = document.getElementById('tool-options'); + const fileDisplayArea = document.getElementById('file-display-area'); + const toolOptions = document.getElementById('tool-options'); - if (!fileDisplayArea) return; + if (!fileDisplayArea) return; - fileDisplayArea.innerHTML = ''; + fileDisplayArea.innerHTML = ''; - if (pageState.file) { - const fileDiv = document.createElement('div'); - fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm'; + if (pageState.file) { + const fileDiv = document.createElement('div'); + fileDiv.className = + 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm'; - const infoContainer = document.createElement('div'); - infoContainer.className = 'flex flex-col overflow-hidden'; + const infoContainer = document.createElement('div'); + infoContainer.className = 'flex flex-col overflow-hidden'; - const nameSpan = document.createElement('div'); - nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1'; - nameSpan.textContent = pageState.file.name; + const nameSpan = document.createElement('div'); + nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1'; + nameSpan.textContent = pageState.file.name; - const metaSpan = document.createElement('div'); - metaSpan.className = 'text-xs text-gray-400'; - metaSpan.textContent = `${formatBytes(pageState.file.size)} • Loading...`; + const metaSpan = document.createElement('div'); + metaSpan.className = 'text-xs text-gray-400'; + metaSpan.textContent = `${formatBytes(pageState.file.size)} • Loading...`; - infoContainer.append(nameSpan, metaSpan); + infoContainer.append(nameSpan, metaSpan); - const removeBtn = document.createElement('button'); - removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0'; - removeBtn.innerHTML = ''; - removeBtn.onclick = function () { - resetState(); - }; + const removeBtn = document.createElement('button'); + removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0'; + removeBtn.innerHTML = ''; + removeBtn.onclick = function () { + resetState(); + }; - fileDiv.append(infoContainer, removeBtn); - fileDisplayArea.appendChild(fileDiv); - createIcons({ icons }); + fileDiv.append(infoContainer, removeBtn); + fileDisplayArea.appendChild(fileDiv); + createIcons({ icons }); - try { - showLoader('Loading PDF...'); - const arrayBuffer = await pageState.file.arrayBuffer(); - pageState.pdfBytes = new Uint8Array(arrayBuffer); + try { + showLoader('Loading PDF...'); + const arrayBuffer = await pageState.file.arrayBuffer(); + pageState.pdfBytes = new Uint8Array(arrayBuffer); - pageState.pdfDoc = await PDFLibDocument.load(pageState.pdfBytes, { - ignoreEncryption: true, - throwOnInvalidObject: false - }); + pageState.pdfDoc = await PDFLibDocument.load(pageState.pdfBytes, { + ignoreEncryption: true, + throwOnInvalidObject: false, + }); - pageState.pdfjsDoc = await pdfjsLib.getDocument({ data: pageState.pdfBytes.slice() }).promise; + pageState.pdfjsDoc = await pdfjsLib.getDocument({ + data: pageState.pdfBytes.slice(), + }).promise; - hideLoader(); + hideLoader(); - const pageCount = pageState.pdfDoc.getPageCount(); - metaSpan.textContent = `${formatBytes(pageState.file.size)} • ${pageCount} pages`; + const pageCount = pageState.pdfDoc.getPageCount(); + metaSpan.textContent = `${formatBytes(pageState.file.size)} • ${pageCount} pages`; - if (toolOptions) toolOptions.classList.remove('hidden'); + if (toolOptions) toolOptions.classList.remove('hidden'); - const previewBtn = document.getElementById('preview-btn') as HTMLButtonElement; - if (previewBtn) previewBtn.disabled = false; - } catch (error) { - console.error('Error loading PDF:', error); - hideLoader(); - showAlert('Error', 'Failed to load PDF file.'); - resetState(); - } - } else { - if (toolOptions) toolOptions.classList.add('hidden'); + const previewBtn = document.getElementById( + 'preview-btn' + ) as HTMLButtonElement; + if (previewBtn) previewBtn.disabled = false; + } catch (error) { + console.error('Error loading PDF:', error); + hideLoader(); + showAlert('Error', 'Failed to load PDF file.'); + resetState(); } + } else { + if (toolOptions) toolOptions.classList.add('hidden'); + } } function getGridDimensions(): { rows: number; cols: number } { - const gridMode = (document.querySelector('input[name="grid-mode"]:checked') as HTMLInputElement)?.value || '1x2'; - switch (gridMode) { - case '1x2': return { rows: 1, cols: 2 }; - case '2x2': return { rows: 2, cols: 2 }; - case '2x4': return { rows: 2, cols: 4 }; - case '4x4': return { rows: 4, cols: 4 }; - default: return { rows: 1, cols: 2 }; - } + const gridMode = + ( + document.querySelector( + 'input[name="grid-mode"]:checked' + ) as HTMLInputElement + )?.value || '1x2'; + switch (gridMode) { + case '1x2': + return { rows: 1, cols: 2 }; + case '2x2': + return { rows: 2, cols: 2 }; + case '2x4': + return { rows: 2, cols: 4 }; + case '4x4': + return { rows: 4, cols: 4 }; + default: + return { rows: 1, cols: 2 }; + } } function getOrientation(isBookletMode: boolean): 'portrait' | 'landscape' { - const orientationValue = (document.querySelector('input[name="orientation"]:checked') as HTMLInputElement)?.value || 'auto'; - if (orientationValue === 'portrait') return 'portrait'; - if (orientationValue === 'landscape') return 'landscape'; - return isBookletMode ? 'landscape' : 'portrait'; + const orientationValue = + ( + document.querySelector( + 'input[name="orientation"]:checked' + ) as HTMLInputElement + )?.value || 'auto'; + if (orientationValue === 'portrait') return 'portrait'; + if (orientationValue === 'landscape') return 'landscape'; + return isBookletMode ? 'landscape' : 'portrait'; } -function getSheetDimensions(isBookletMode: boolean): { width: number; height: number } { - const paperSizeKey = (document.getElementById('paper-size') as HTMLSelectElement).value as keyof typeof PageSizes; - const pageDims = PageSizes[paperSizeKey] || PageSizes.Letter; - const orientation = getOrientation(isBookletMode); - if (orientation === 'landscape') { - return { width: pageDims[1], height: pageDims[0] }; - } - return { width: pageDims[0], height: pageDims[1] }; +function getSheetDimensions(isBookletMode: boolean): { + width: number; + height: number; +} { + const paperSizeKey = ( + document.getElementById('paper-size') as HTMLSelectElement + ).value as keyof typeof PageSizes; + const pageDims = PageSizes[paperSizeKey] || PageSizes.Letter; + const orientation = getOrientation(isBookletMode); + if (orientation === 'landscape') { + return { width: pageDims[1], height: pageDims[0] }; + } + return { width: pageDims[0], height: pageDims[1] }; } async function generatePreview() { - if (!pageState.pdfDoc || !pageState.pdfjsDoc) { - showAlert('Error', 'Please load a PDF first.'); - return; + if (!pageState.pdfDoc || !pageState.pdfjsDoc) { + showAlert('Error', 'Please load a PDF first.'); + return; + } + + const previewArea = document.getElementById('booklet-preview')!; + const totalPages = pageState.pdfDoc.getPageCount(); + const { rows, cols } = getGridDimensions(); + const pagesPerSheet = rows * cols; + const isBookletMode = rows === 1 && cols === 2; + + let numSheets: number; + if (isBookletMode) { + const sheetsNeeded = Math.ceil(totalPages / 4); + numSheets = sheetsNeeded * 2; + } else { + numSheets = Math.ceil(totalPages / pagesPerSheet); + } + + const { width: sheetWidth, height: sheetHeight } = + getSheetDimensions(isBookletMode); + + // Get container width to make canvas fill it + const previewContainer = document.getElementById('booklet-preview')!; + const containerWidth = previewContainer.clientWidth - 32; // account for padding + const aspectRatio = sheetWidth / sheetHeight; + const canvasWidth = containerWidth; + const canvasHeight = containerWidth / aspectRatio; + + previewArea.innerHTML = + '

Generating preview...

'; + + const totalRounded = isBookletMode + ? Math.ceil(totalPages / 4) * 4 + : totalPages; + const rotationMode = + ( + document.querySelector( + 'input[name="rotation"]:checked' + ) as HTMLInputElement + )?.value || 'none'; + + const pageThumbnails: Map = new Map(); + const thumbnailScale = 1; + + for (let i = 1; i <= totalPages; i++) { + try { + const page = await pageState.pdfjsDoc.getPage(i); + const viewport = page.getViewport({ scale: thumbnailScale }); + + const offscreen = new OffscreenCanvas(viewport.width, viewport.height); + const ctx = offscreen.getContext('2d')!; + + await page.render({ + canvasContext: ctx as any, + viewport: viewport, + canvas: offscreen as any, + }).promise; + + const bitmap = await createImageBitmap(offscreen); + pageThumbnails.set(i, bitmap); + } catch (e) { + console.error(`Failed to render page ${i}:`, e); + } + } + + previewArea.innerHTML = `

${totalPages} pages → ${numSheets} output sheets

`; + + for (let sheetIndex = 0; sheetIndex < numSheets; sheetIndex++) { + const canvas = document.createElement('canvas'); + canvas.width = canvasWidth; + canvas.height = canvasHeight; + canvas.className = 'border border-gray-600 rounded-lg mb-4'; + + const ctx = canvas.getContext('2d')!; + + const isFront = sheetIndex % 2 === 0; + ctx.fillStyle = isFront ? '#1f2937' : '#1a2e1a'; + ctx.fillRect(0, 0, canvasWidth, canvasHeight); + + ctx.strokeStyle = '#4b5563'; + ctx.lineWidth = 1; + ctx.strokeRect(0, 0, canvasWidth, canvasHeight); + + const cellWidth = canvasWidth / cols; + const cellHeight = canvasHeight / rows; + const padding = 4; + + ctx.strokeStyle = '#374151'; + ctx.lineWidth = 1; + ctx.setLineDash([2, 2]); + for (let c = 1; c < cols; c++) { + ctx.beginPath(); + ctx.moveTo(c * cellWidth, 0); + ctx.lineTo(c * cellWidth, canvasHeight); + ctx.stroke(); + } + for (let r = 1; r < rows; r++) { + ctx.beginPath(); + ctx.moveTo(0, r * cellHeight); + ctx.lineTo(canvasWidth, r * cellHeight); + ctx.stroke(); + } + ctx.setLineDash([]); + + for (let r = 0; r < rows; r++) { + for (let c = 0; c < cols; c++) { + const slotIndex = r * cols + c; + let pageNumber: number; + + if (isBookletMode) { + const physicalSheet = Math.floor(sheetIndex / 2); + const isFrontSide = sheetIndex % 2 === 0; + if (isFrontSide) { + pageNumber = + c === 0 + ? totalRounded - 2 * physicalSheet + : 2 * physicalSheet + 1; + } else { + pageNumber = + c === 0 + ? 2 * physicalSheet + 2 + : totalRounded - 2 * physicalSheet - 1; + } + } else { + pageNumber = sheetIndex * pagesPerSheet + slotIndex + 1; + } + + const x = c * cellWidth + padding; + const y = r * cellHeight + padding; + const slotWidth = cellWidth - padding * 2; + const slotHeight = cellHeight - padding * 2; + + const exists = pageNumber >= 1 && pageNumber <= totalPages; + + if (exists) { + const thumbnail = pageThumbnails.get(pageNumber); + if (thumbnail) { + let rotation = 0; + if (rotationMode === '90cw') rotation = 90; + else if (rotationMode === '90ccw') rotation = -90; + else if (rotationMode === 'alternate') + rotation = pageNumber % 2 === 1 ? 90 : -90; + + const isRotated = rotation !== 0; + const srcWidth = isRotated ? thumbnail.height : thumbnail.width; + const srcHeight = isRotated ? thumbnail.width : thumbnail.height; + const scale = Math.min( + slotWidth / srcWidth, + slotHeight / srcHeight + ); + const drawWidth = srcWidth * scale; + const drawHeight = srcHeight * scale; + const drawX = x + (slotWidth - drawWidth) / 2; + const drawY = y + (slotHeight - drawHeight) / 2; + + ctx.save(); + if (rotation !== 0) { + const centerX = drawX + drawWidth / 2; + const centerY = drawY + drawHeight / 2; + ctx.translate(centerX, centerY); + ctx.rotate((rotation * Math.PI) / 180); + ctx.drawImage( + thumbnail, + -drawHeight / 2, + -drawWidth / 2, + drawHeight, + drawWidth + ); + } else { + ctx.drawImage(thumbnail, drawX, drawY, drawWidth, drawHeight); + } + ctx.restore(); + + ctx.strokeStyle = '#6b7280'; + ctx.lineWidth = 1; + ctx.strokeRect(drawX, drawY, drawWidth, drawHeight); + + ctx.fillStyle = 'rgba(255, 255, 255, 0.8)'; + ctx.font = 'bold 10px sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText( + `${pageNumber}`, + x + slotWidth / 2, + y + slotHeight - 4 + ); + } + } else { + ctx.fillStyle = '#374151'; + ctx.fillRect(x, y, slotWidth, slotHeight); + ctx.strokeStyle = '#4b5563'; + ctx.lineWidth = 1; + ctx.strokeRect(x, y, slotWidth, slotHeight); + + ctx.fillStyle = '#6b7280'; + ctx.font = '10px sans-serif'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText('(blank)', x + slotWidth / 2, y + slotHeight / 2); + } + } } - const previewArea = document.getElementById('booklet-preview')!; - const totalPages = pageState.pdfDoc.getPageCount(); + ctx.fillStyle = '#9ca3af'; + ctx.font = 'bold 10px sans-serif'; + ctx.textAlign = 'right'; + ctx.textBaseline = 'top'; + const sideLabel = isBookletMode ? (isFront ? 'Front' : 'Back') : ''; + ctx.fillText( + `Sheet ${Math.floor(sheetIndex / (isBookletMode ? 2 : 1)) + 1} ${sideLabel}`, + canvasWidth - 6, + 4 + ); + + previewArea.appendChild(canvas); + } + + pageThumbnails.forEach((bitmap) => bitmap.close()); + + const downloadBtn = document.getElementById( + 'download-btn' + ) as HTMLButtonElement; + downloadBtn.disabled = false; +} + +function applyRotation(doc: PDFLibDocument, mode: string) { + const pages = doc.getPages(); + pages.forEach((page, index) => { + let rotation: number; + switch (mode) { + case '90cw': + rotation = 90; + break; + case '90ccw': + rotation = -90; + break; + case 'alternate': + rotation = index % 2 === 0 ? 90 : -90; + break; + default: + rotation = 0; + } + if (rotation !== 0) { + page.setRotation(degrees(page.getRotation().angle + rotation)); + } + }); +} + +async function createBooklet() { + if (!pageState.pdfBytes) { + showAlert('Error', 'Please load a PDF first.'); + return; + } + + showLoader('Creating Booklet...'); + + try { + const sourceDoc = await PDFLibDocument.load(pageState.pdfBytes.slice()); + const rotationMode = + ( + document.querySelector( + 'input[name="rotation"]:checked' + ) as HTMLInputElement + )?.value || 'none'; + applyRotation(sourceDoc, rotationMode); + + const totalPages = sourceDoc.getPageCount(); const { rows, cols } = getGridDimensions(); const pagesPerSheet = rows * cols; const isBookletMode = rows === 1 && cols === 2; + const { width: sheetWidth, height: sheetHeight } = + getSheetDimensions(isBookletMode); + + const outputDoc = await PDFLibDocument.create(); + let numSheets: number; + let totalRounded: number; if (isBookletMode) { - const sheetsNeeded = Math.ceil(totalPages / 4); - numSheets = sheetsNeeded * 2; + totalRounded = Math.ceil(totalPages / 4) * 4; + numSheets = Math.ceil(totalPages / 4) * 2; } else { - numSheets = Math.ceil(totalPages / pagesPerSheet); + totalRounded = totalPages; + numSheets = Math.ceil(totalPages / pagesPerSheet); } - const { width: sheetWidth, height: sheetHeight } = getSheetDimensions(isBookletMode); - - // Get container width to make canvas fill it - const previewContainer = document.getElementById('booklet-preview')!; - const containerWidth = previewContainer.clientWidth - 32; // account for padding - const aspectRatio = sheetWidth / sheetHeight; - const canvasWidth = containerWidth; - const canvasHeight = containerWidth / aspectRatio; - - previewArea.innerHTML = '

Generating preview...

'; - - const totalRounded = isBookletMode ? Math.ceil(totalPages / 4) * 4 : totalPages; - const rotationMode = (document.querySelector('input[name="rotation"]:checked') as HTMLInputElement)?.value || 'none'; - - const pageThumbnails: Map = new Map(); - const thumbnailScale = 0.3; - - for (let i = 1; i <= totalPages; i++) { - try { - const page = await pageState.pdfjsDoc.getPage(i); - const viewport = page.getViewport({ scale: thumbnailScale }); - - const offscreen = new OffscreenCanvas(viewport.width, viewport.height); - const ctx = offscreen.getContext('2d')!; - - await page.render({ - canvasContext: ctx as any, - viewport: viewport, - canvas: offscreen as any, - }).promise; - - const bitmap = await createImageBitmap(offscreen); - pageThumbnails.set(i, bitmap); - } catch (e) { - console.error(`Failed to render page ${i}:`, e); - } - } - - previewArea.innerHTML = `

${totalPages} pages → ${numSheets} output sheets

`; + const cellWidth = sheetWidth / cols; + const cellHeight = sheetHeight / rows; + const padding = 10; for (let sheetIndex = 0; sheetIndex < numSheets; sheetIndex++) { - const canvas = document.createElement('canvas'); - canvas.width = canvasWidth; - canvas.height = canvasHeight; - canvas.className = 'border border-gray-600 rounded-lg mb-4'; + const outputPage = outputDoc.addPage([sheetWidth, sheetHeight]); - const ctx = canvas.getContext('2d')!; + for (let r = 0; r < rows; r++) { + for (let c = 0; c < cols; c++) { + const slotIndex = r * cols + c; + let pageNumber: number; - const isFront = sheetIndex % 2 === 0; - ctx.fillStyle = isFront ? '#1f2937' : '#1a2e1a'; - ctx.fillRect(0, 0, canvasWidth, canvasHeight); - - ctx.strokeStyle = '#4b5563'; - ctx.lineWidth = 1; - ctx.strokeRect(0, 0, canvasWidth, canvasHeight); - - const cellWidth = canvasWidth / cols; - const cellHeight = canvasHeight / rows; - const padding = 4; - - ctx.strokeStyle = '#374151'; - ctx.lineWidth = 1; - ctx.setLineDash([2, 2]); - for (let c = 1; c < cols; c++) { - ctx.beginPath(); - ctx.moveTo(c * cellWidth, 0); - ctx.lineTo(c * cellWidth, canvasHeight); - ctx.stroke(); - } - for (let r = 1; r < rows; r++) { - ctx.beginPath(); - ctx.moveTo(0, r * cellHeight); - ctx.lineTo(canvasWidth, r * cellHeight); - ctx.stroke(); - } - ctx.setLineDash([]); - - for (let r = 0; r < rows; r++) { - for (let c = 0; c < cols; c++) { - const slotIndex = r * cols + c; - let pageNumber: number; - - if (isBookletMode) { - const physicalSheet = Math.floor(sheetIndex / 2); - const isFrontSide = sheetIndex % 2 === 0; - if (isFrontSide) { - pageNumber = c === 0 ? totalRounded - 2 * physicalSheet : 2 * physicalSheet + 1; - } else { - pageNumber = c === 0 ? 2 * physicalSheet + 2 : totalRounded - 2 * physicalSheet - 1; - } - } else { - pageNumber = sheetIndex * pagesPerSheet + slotIndex + 1; - } - - const x = c * cellWidth + padding; - const y = r * cellHeight + padding; - const slotWidth = cellWidth - padding * 2; - const slotHeight = cellHeight - padding * 2; - - const exists = pageNumber >= 1 && pageNumber <= totalPages; - - if (exists) { - const thumbnail = pageThumbnails.get(pageNumber); - if (thumbnail) { - let rotation = 0; - if (rotationMode === '90cw') rotation = 90; - else if (rotationMode === '90ccw') rotation = -90; - else if (rotationMode === 'alternate') rotation = (pageNumber % 2 === 1) ? 90 : -90; - - const isRotated = rotation !== 0; - const srcWidth = isRotated ? thumbnail.height : thumbnail.width; - const srcHeight = isRotated ? thumbnail.width : thumbnail.height; - const scale = Math.min(slotWidth / srcWidth, slotHeight / srcHeight); - const drawWidth = srcWidth * scale; - const drawHeight = srcHeight * scale; - const drawX = x + (slotWidth - drawWidth) / 2; - const drawY = y + (slotHeight - drawHeight) / 2; - - ctx.save(); - if (rotation !== 0) { - const centerX = drawX + drawWidth / 2; - const centerY = drawY + drawHeight / 2; - ctx.translate(centerX, centerY); - ctx.rotate((rotation * Math.PI) / 180); - ctx.drawImage(thumbnail, -drawHeight / 2, -drawWidth / 2, drawHeight, drawWidth); - } else { - ctx.drawImage(thumbnail, drawX, drawY, drawWidth, drawHeight); - } - ctx.restore(); - - ctx.strokeStyle = '#6b7280'; - ctx.lineWidth = 1; - ctx.strokeRect(drawX, drawY, drawWidth, drawHeight); - - ctx.fillStyle = 'rgba(255, 255, 255, 0.8)'; - ctx.font = 'bold 10px sans-serif'; - ctx.textAlign = 'center'; - ctx.fillText(`${pageNumber}`, x + slotWidth / 2, y + slotHeight - 4); - } - } else { - ctx.fillStyle = '#374151'; - ctx.fillRect(x, y, slotWidth, slotHeight); - ctx.strokeStyle = '#4b5563'; - ctx.lineWidth = 1; - ctx.strokeRect(x, y, slotWidth, slotHeight); - - ctx.fillStyle = '#6b7280'; - ctx.font = '10px sans-serif'; - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - ctx.fillText('(blank)', x + slotWidth / 2, y + slotHeight / 2); - } + if (isBookletMode) { + const physicalSheet = Math.floor(sheetIndex / 2); + const isFrontSide = sheetIndex % 2 === 0; + if (isFrontSide) { + pageNumber = + c === 0 + ? totalRounded - 2 * physicalSheet + : 2 * physicalSheet + 1; + } else { + pageNumber = + c === 0 + ? 2 * physicalSheet + 2 + : totalRounded - 2 * physicalSheet - 1; } + } else { + pageNumber = sheetIndex * pagesPerSheet + slotIndex + 1; + } + + if (pageNumber >= 1 && pageNumber <= totalPages) { + const [embeddedPage] = await outputDoc.embedPdf(sourceDoc, [ + pageNumber - 1, + ]); + const { width: srcW, height: srcH } = embeddedPage; + + const availableWidth = cellWidth - padding * 2; + const availableHeight = cellHeight - padding * 2; + const scale = Math.min( + availableWidth / srcW, + availableHeight / srcH + ); + + const scaledWidth = srcW * scale; + const scaledHeight = srcH * scale; + + const x = + c * cellWidth + padding + (availableWidth - scaledWidth) / 2; + const y = + sheetHeight - + (r + 1) * cellHeight + + padding + + (availableHeight - scaledHeight) / 2; + + outputPage.drawPage(embeddedPage, { + x, + y, + width: scaledWidth, + height: scaledHeight, + }); + } } - - ctx.fillStyle = '#9ca3af'; - ctx.font = 'bold 10px sans-serif'; - ctx.textAlign = 'right'; - ctx.textBaseline = 'top'; - const sideLabel = isBookletMode ? (isFront ? 'Front' : 'Back') : ''; - ctx.fillText(`Sheet ${Math.floor(sheetIndex / (isBookletMode ? 2 : 1)) + 1} ${sideLabel}`, canvasWidth - 6, 4); - - previewArea.appendChild(canvas); + } } - pageThumbnails.forEach(bitmap => bitmap.close()); + const pdfBytes = await outputDoc.save(); + const originalName = + pageState.file?.name.replace(/\.pdf$/i, '') || 'document'; - const downloadBtn = document.getElementById('download-btn') as HTMLButtonElement; - downloadBtn.disabled = false; -} + downloadFile( + new Blob([new Uint8Array(pdfBytes)], { type: 'application/pdf' }), + `${originalName}_booklet.pdf` + ); -function applyRotation(doc: PDFLibDocument, mode: string) { - const pages = doc.getPages(); - pages.forEach((page, index) => { - let rotation = 0; - switch (mode) { - case '90cw': rotation = 90; break; - case '90ccw': rotation = -90; break; - case 'alternate': rotation = (index % 2 === 0) ? 90 : -90; break; - default: rotation = 0; - } - if (rotation !== 0) { - page.setRotation(degrees(page.getRotation().angle + rotation)); - } - }); -} - -async function createBooklet() { - if (!pageState.pdfBytes) { - showAlert('Error', 'Please load a PDF first.'); - return; - } - - showLoader('Creating Booklet...'); - - try { - const sourceDoc = await PDFLibDocument.load(pageState.pdfBytes.slice()); - const rotationMode = (document.querySelector('input[name="rotation"]:checked') as HTMLInputElement)?.value || 'none'; - applyRotation(sourceDoc, rotationMode); - - const totalPages = sourceDoc.getPageCount(); - const { rows, cols } = getGridDimensions(); - const pagesPerSheet = rows * cols; - const isBookletMode = rows === 1 && cols === 2; - - const { width: sheetWidth, height: sheetHeight } = getSheetDimensions(isBookletMode); - - const outputDoc = await PDFLibDocument.create(); - - let numSheets: number; - let totalRounded: number; - if (isBookletMode) { - totalRounded = Math.ceil(totalPages / 4) * 4; - numSheets = Math.ceil(totalPages / 4) * 2; - } else { - totalRounded = totalPages; - numSheets = Math.ceil(totalPages / pagesPerSheet); - } - - const cellWidth = sheetWidth / cols; - const cellHeight = sheetHeight / rows; - const padding = 10; - - for (let sheetIndex = 0; sheetIndex < numSheets; sheetIndex++) { - const outputPage = outputDoc.addPage([sheetWidth, sheetHeight]); - - for (let r = 0; r < rows; r++) { - for (let c = 0; c < cols; c++) { - const slotIndex = r * cols + c; - let pageNumber: number; - - if (isBookletMode) { - const physicalSheet = Math.floor(sheetIndex / 2); - const isFrontSide = sheetIndex % 2 === 0; - if (isFrontSide) { - pageNumber = c === 0 ? totalRounded - 2 * physicalSheet : 2 * physicalSheet + 1; - } else { - pageNumber = c === 0 ? 2 * physicalSheet + 2 : totalRounded - 2 * physicalSheet - 1; - } - } else { - pageNumber = sheetIndex * pagesPerSheet + slotIndex + 1; - } - - if (pageNumber >= 1 && pageNumber <= totalPages) { - const [embeddedPage] = await outputDoc.embedPdf(sourceDoc, [pageNumber - 1]); - const { width: srcW, height: srcH } = embeddedPage; - - const availableWidth = cellWidth - padding * 2; - const availableHeight = cellHeight - padding * 2; - const scale = Math.min(availableWidth / srcW, availableHeight / srcH); - - const scaledWidth = srcW * scale; - const scaledHeight = srcH * scale; - - const x = c * cellWidth + padding + (availableWidth - scaledWidth) / 2; - const y = sheetHeight - (r + 1) * cellHeight + padding + (availableHeight - scaledHeight) / 2; - - outputPage.drawPage(embeddedPage, { - x, - y, - width: scaledWidth, - height: scaledHeight, - }); - } - } - } - } - - const pdfBytes = await outputDoc.save(); - const originalName = pageState.file?.name.replace(/\.pdf$/i, '') || 'document'; - - downloadFile( - new Blob([new Uint8Array(pdfBytes)], { type: 'application/pdf' }), - `${originalName}_booklet.pdf` - ); - - showAlert('Success', `Booklet created with ${numSheets} sheets!`, 'success', function () { - resetState(); - }); - } catch (e) { - console.error(e); - showAlert('Error', 'An error occurred while creating the booklet.'); - } finally { - hideLoader(); - } + showAlert( + 'Success', + `Booklet created with ${numSheets} sheets!`, + 'success', + function () { + resetState(); + } + ); + } catch (e) { + console.error(e); + showAlert('Error', 'An error occurred while creating the booklet.'); + } finally { + hideLoader(); + } } function handleFileSelect(files: FileList | null) { - if (files && files.length > 0) { - const file = files[0]; - if (file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf')) { - pageState.file = file; - updateUI(); - } + if (files && files.length > 0) { + const file = files[0]; + if ( + file.type === 'application/pdf' || + file.name.toLowerCase().endsWith('.pdf') + ) { + pageState.file = file; + updateUI(); } + } } document.addEventListener('DOMContentLoaded', function () { - const fileInput = document.getElementById('file-input') as HTMLInputElement; - const dropZone = document.getElementById('drop-zone'); - const previewBtn = document.getElementById('preview-btn'); - const downloadBtn = document.getElementById('download-btn'); - const backBtn = document.getElementById('back-to-tools'); + const fileInput = document.getElementById('file-input') as HTMLInputElement; + const dropZone = document.getElementById('drop-zone'); + const previewBtn = document.getElementById('preview-btn'); + const downloadBtn = document.getElementById('download-btn'); + const backBtn = document.getElementById('back-to-tools'); - if (backBtn) { - backBtn.addEventListener('click', function () { - window.location.href = import.meta.env.BASE_URL; + if (backBtn) { + backBtn.addEventListener('click', function () { + window.location.href = import.meta.env.BASE_URL; + }); + } + + if (fileInput && dropZone) { + fileInput.addEventListener('change', function (e) { + handleFileSelect((e.target as HTMLInputElement).files); + }); + + dropZone.addEventListener('dragover', function (e) { + e.preventDefault(); + dropZone.classList.add('bg-gray-700'); + }); + + dropZone.addEventListener('dragleave', function (e) { + e.preventDefault(); + dropZone.classList.remove('bg-gray-700'); + }); + + dropZone.addEventListener('drop', function (e) { + e.preventDefault(); + dropZone.classList.remove('bg-gray-700'); + const files = e.dataTransfer?.files; + if (files && files.length > 0) { + const pdfFiles = Array.from(files).filter(function (f) { + return ( + f.type === 'application/pdf' || + f.name.toLowerCase().endsWith('.pdf') + ); }); - } + if (pdfFiles.length > 0) { + const dataTransfer = new DataTransfer(); + dataTransfer.items.add(pdfFiles[0]); + handleFileSelect(dataTransfer.files); + } + } + }); - if (fileInput && dropZone) { - fileInput.addEventListener('change', function (e) { - handleFileSelect((e.target as HTMLInputElement).files); - }); + fileInput.addEventListener('click', function () { + fileInput.value = ''; + }); + } - dropZone.addEventListener('dragover', function (e) { - e.preventDefault(); - dropZone.classList.add('bg-gray-700'); - }); + if (previewBtn) { + previewBtn.addEventListener('click', generatePreview); + } - dropZone.addEventListener('dragleave', function (e) { - e.preventDefault(); - dropZone.classList.remove('bg-gray-700'); - }); - - dropZone.addEventListener('drop', function (e) { - e.preventDefault(); - dropZone.classList.remove('bg-gray-700'); - const files = e.dataTransfer?.files; - if (files && files.length > 0) { - const pdfFiles = Array.from(files).filter(function (f) { - return f.type === 'application/pdf' || f.name.toLowerCase().endsWith('.pdf'); - }); - if (pdfFiles.length > 0) { - const dataTransfer = new DataTransfer(); - dataTransfer.items.add(pdfFiles[0]); - handleFileSelect(dataTransfer.files); - } - } - }); - - fileInput.addEventListener('click', function () { - fileInput.value = ''; - }); - } - - if (previewBtn) { - previewBtn.addEventListener('click', generatePreview); - } - - if (downloadBtn) { - downloadBtn.addEventListener('click', createBooklet); - } + if (downloadBtn) { + downloadBtn.addEventListener('click', createBooklet); + } }); diff --git a/src/js/logic/pdf-layers-page.ts b/src/js/logic/pdf-layers-page.ts index 2975e7a..22ea415 100644 --- a/src/js/logic/pdf-layers-page.ts +++ b/src/js/logic/pdf-layers-page.ts @@ -1,21 +1,25 @@ import { showLoader, hideLoader, showAlert } from '../ui.js'; -import { downloadFile, readFileAsArrayBuffer, formatBytes, getPDFDocument } from '../utils/helpers.js'; +import { + downloadFile, + readFileAsArrayBuffer, + formatBytes, + getPDFDocument, +} from '../utils/helpers.js'; import { createIcons, icons } from 'lucide'; -import { PyMuPDF } from '@bentopdf/pymupdf-wasm'; -import { getWasmBaseUrl } from '../config/wasm-cdn-config.js'; - -const pymupdf = new PyMuPDF(getWasmBaseUrl('pymupdf')); +import { isWasmAvailable, getWasmBaseUrl } from '../config/wasm-cdn-config.js'; +import { showWasmRequiredDialog } from '../utils/wasm-provider.js'; +import { loadPyMuPDF, isPyMuPDFAvailable } from '../utils/pymupdf-loader.js'; interface LayerData { - number: number; - xref: number; - text: string; - on: boolean; - locked: boolean; - depth: number; - parentXref: number; - displayOrder: number; -}; + number: number; + xref: number; + text: string; + on: boolean; + locked: boolean; + depth: number; + parentXref: number; + displayOrder: number; +} let currentFile: File | null = null; let currentDoc: any = null; @@ -23,151 +27,170 @@ const layersMap = new Map(); let nextDisplayOrder = 0; document.addEventListener('DOMContentLoaded', () => { - const fileInput = document.getElementById('file-input') as HTMLInputElement; - const dropZone = document.getElementById('drop-zone'); - const processBtn = document.getElementById('process-btn'); - const processBtnContainer = document.getElementById('process-btn-container'); - const fileDisplayArea = document.getElementById('file-display-area'); - const layersContainer = document.getElementById('layers-container'); - const layersList = document.getElementById('layers-list'); - const backBtn = document.getElementById('back-to-tools'); + const fileInput = document.getElementById('file-input') as HTMLInputElement; + const dropZone = document.getElementById('drop-zone'); + const processBtn = document.getElementById('process-btn'); + const processBtnContainer = document.getElementById('process-btn-container'); + const fileDisplayArea = document.getElementById('file-display-area'); + const layersContainer = document.getElementById('layers-container'); + const layersList = document.getElementById('layers-list'); + const backBtn = document.getElementById('back-to-tools'); - if (backBtn) { - backBtn.addEventListener('click', () => { - window.location.href = import.meta.env.BASE_URL; - }); + if (backBtn) { + backBtn.addEventListener('click', () => { + window.location.href = import.meta.env.BASE_URL; + }); + } + + const updateUI = async () => { + if (!fileDisplayArea || !processBtnContainer || !processBtn) return; + + if (currentFile) { + fileDisplayArea.innerHTML = ''; + const fileDiv = document.createElement('div'); + fileDiv.className = + 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm'; + + const infoContainer = document.createElement('div'); + infoContainer.className = 'flex flex-col overflow-hidden'; + + const nameSpan = document.createElement('div'); + nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1'; + nameSpan.textContent = currentFile.name; + + const metaSpan = document.createElement('div'); + metaSpan.className = 'text-xs text-gray-400'; + metaSpan.textContent = `${formatBytes(currentFile.size)} • Loading pages...`; + + 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); + + try { + const arrayBuffer = await readFileAsArrayBuffer(currentFile); + const pdfDoc = await getPDFDocument({ data: arrayBuffer }).promise; + metaSpan.textContent = `${formatBytes(currentFile.size)} • ${pdfDoc.numPages} pages`; + } catch (error) { + console.error('Error loading PDF:', error); + metaSpan.textContent = `${formatBytes(currentFile.size)} • Could not load page count`; + } + + createIcons({ icons }); + processBtnContainer.classList.remove('hidden'); + (processBtn as HTMLButtonElement).disabled = false; + } else { + fileDisplayArea.innerHTML = ''; + processBtnContainer.classList.add('hidden'); + (processBtn as HTMLButtonElement).disabled = true; } + }; - const updateUI = async () => { - if (!fileDisplayArea || !processBtnContainer || !processBtn) return; + const resetState = () => { + currentFile = null; + currentDoc = null; + layersMap.clear(); + nextDisplayOrder = 0; - if (currentFile) { - fileDisplayArea.innerHTML = ''; - const fileDiv = document.createElement('div'); - fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm'; + if (dropZone) dropZone.style.display = 'flex'; + if (layersContainer) layersContainer.classList.add('hidden'); + updateUI(); + }; - const infoContainer = document.createElement('div'); - infoContainer.className = 'flex flex-col overflow-hidden'; + const promptForInput = ( + title: string, + message: string, + defaultValue: string = '' + ): Promise => { + return new Promise((resolve) => { + const modal = document.getElementById('input-modal'); + const titleEl = document.getElementById('input-title'); + const messageEl = document.getElementById('input-message'); + const inputEl = document.getElementById( + 'input-value' + ) as HTMLInputElement; + const confirmBtn = document.getElementById('input-confirm'); + const cancelBtn = document.getElementById('input-cancel'); - const nameSpan = document.createElement('div'); - nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1'; - nameSpan.textContent = currentFile.name; + if ( + !modal || + !titleEl || + !messageEl || + !inputEl || + !confirmBtn || + !cancelBtn + ) { + console.error('Input modal elements not found'); + resolve(null); + return; + } - const metaSpan = document.createElement('div'); - metaSpan.className = 'text-xs text-gray-400'; - metaSpan.textContent = `${formatBytes(currentFile.size)} • Loading pages...`; + titleEl.textContent = title; + messageEl.textContent = message; + inputEl.value = defaultValue; - infoContainer.append(nameSpan, metaSpan); + const closeModal = () => { + modal.classList.add('hidden'); + confirmBtn.onclick = null; + cancelBtn.onclick = null; + inputEl.onkeydown = null; + }; - const removeBtn = document.createElement('button'); - removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0'; - removeBtn.innerHTML = ''; - removeBtn.onclick = () => { - resetState(); - }; + const confirm = () => { + const val = inputEl.value.trim(); + closeModal(); + resolve(val); + }; - fileDiv.append(infoContainer, removeBtn); - fileDisplayArea.appendChild(fileDiv); + const cancel = () => { + closeModal(); + resolve(null); + }; - try { - const arrayBuffer = await readFileAsArrayBuffer(currentFile); - const pdfDoc = await getPDFDocument({ data: arrayBuffer }).promise; - metaSpan.textContent = `${formatBytes(currentFile.size)} • ${pdfDoc.numPages} pages`; - } catch (error) { - console.error('Error loading PDF:', error); - metaSpan.textContent = `${formatBytes(currentFile.size)} • Could not load page count`; - } + confirmBtn.onclick = confirm; + cancelBtn.onclick = cancel; - createIcons({ icons }); - processBtnContainer.classList.remove('hidden'); - (processBtn as HTMLButtonElement).disabled = false; - } else { - fileDisplayArea.innerHTML = ''; - processBtnContainer.classList.add('hidden'); - (processBtn as HTMLButtonElement).disabled = true; - } - }; + inputEl.onkeydown = (e) => { + if (e.key === 'Enter') confirm(); + if (e.key === 'Escape') cancel(); + }; - const resetState = () => { - currentFile = null; - currentDoc = null; - layersMap.clear(); - nextDisplayOrder = 0; + modal.classList.remove('hidden'); + inputEl.focus(); + }); + }; - if (dropZone) dropZone.style.display = 'flex'; - if (layersContainer) layersContainer.classList.add('hidden'); - updateUI(); - }; + const renderLayers = () => { + if (!layersList) return; - const promptForInput = (title: string, message: string, defaultValue: string = ''): Promise => { - return new Promise((resolve) => { - const modal = document.getElementById('input-modal'); - const titleEl = document.getElementById('input-title'); - const messageEl = document.getElementById('input-message'); - const inputEl = document.getElementById('input-value') as HTMLInputElement; - const confirmBtn = document.getElementById('input-confirm'); - const cancelBtn = document.getElementById('input-cancel'); + const layersArray = Array.from(layersMap.values()); - if (!modal || !titleEl || !messageEl || !inputEl || !confirmBtn || !cancelBtn) { - console.error('Input modal elements not found'); - resolve(null); - return; - } - - titleEl.textContent = title; - messageEl.textContent = message; - inputEl.value = defaultValue; - - const closeModal = () => { - modal.classList.add('hidden'); - confirmBtn.onclick = null; - cancelBtn.onclick = null; - inputEl.onkeydown = null; - }; - - const confirm = () => { - const val = inputEl.value.trim(); - closeModal(); - resolve(val); - }; - - const cancel = () => { - closeModal(); - resolve(null); - }; - - confirmBtn.onclick = confirm; - cancelBtn.onclick = cancel; - - inputEl.onkeydown = (e) => { - if (e.key === 'Enter') confirm(); - if (e.key === 'Escape') cancel(); - }; - - modal.classList.remove('hidden'); - inputEl.focus(); - }); - }; - - const renderLayers = () => { - if (!layersList) return; - - const layersArray = Array.from(layersMap.values()); - - if (layersArray.length === 0) { - layersList.innerHTML = ` + if (layersArray.length === 0) { + layersList.innerHTML = `

This PDF has no layers (OCG).

Add a new layer to get started!

`; - return; - } + return; + } - // Sort layers by displayOrder - const sortedLayers = layersArray.sort((a, b) => a.displayOrder - b.displayOrder); + // Sort layers by displayOrder + const sortedLayers = layersArray.sort( + (a, b) => a.displayOrder - b.displayOrder + ); - layersList.innerHTML = sortedLayers.map((layer: LayerData) => ` + layersList.innerHTML = sortedLayers + .map( + (layer: LayerData) => `
- `).join(''); + ` + ) + .join(''); - // Attach toggle handlers - layersList.querySelectorAll('input[type="checkbox"]').forEach((checkbox) => { - checkbox.addEventListener('change', (e) => { - const target = e.target as HTMLInputElement; - const xref = parseInt(target.dataset.xref || '0'); - const isOn = target.checked; + // Attach toggle handlers + layersList + .querySelectorAll('input[type="checkbox"]') + .forEach((checkbox) => { + checkbox.addEventListener('change', (e) => { + const target = e.target as HTMLInputElement; + const xref = parseInt(target.dataset.xref || '0'); + const isOn = target.checked; - try { - currentDoc.setLayerVisibility(xref, isOn); - const layer = Array.from(layersMap.values()).find(l => l.xref === xref); - if (layer) { - layer.on = isOn; - } - } catch (err) { - console.error('Failed to set layer visibility:', err); - target.checked = !isOn; - showAlert('Error', 'Failed to toggle layer visibility'); - } - }); + try { + currentDoc.setLayerVisibility(xref, isOn); + const layer = Array.from(layersMap.values()).find( + (l) => l.xref === xref + ); + if (layer) { + layer.on = isOn; + } + } catch (err) { + console.error('Failed to set layer visibility:', err); + target.checked = !isOn; + showAlert('Error', 'Failed to toggle layer visibility'); + } }); + }); - // Attach delete handlers - layersList.querySelectorAll('.layer-delete').forEach((btn) => { - btn.addEventListener('click', (e) => { - const target = e.target as HTMLButtonElement; - const xref = parseInt(target.dataset.xref || '0'); - const layer = Array.from(layersMap.values()).find(l => l.xref === xref); + // Attach delete handlers + layersList.querySelectorAll('.layer-delete').forEach((btn) => { + btn.addEventListener('click', (e) => { + const target = e.target as HTMLButtonElement; + const xref = parseInt(target.dataset.xref || '0'); + const layer = Array.from(layersMap.values()).find( + (l) => l.xref === xref + ); - if (!layer) { - showAlert('Error', 'Layer not found'); - return; - } - - try { - currentDoc.deleteOCG(layer.number); - layersMap.delete(layer.number); - renderLayers(); - } catch (err) { - console.error('Failed to delete layer:', err); - showAlert('Error', 'Failed to delete layer'); - } - }); - }); - - layersList.querySelectorAll('.layer-add-child').forEach((btn) => { - btn.addEventListener('click', async (e) => { - const target = e.target as HTMLButtonElement; - const parentXref = parseInt(target.dataset.xref || '0'); - const parentLayer = Array.from(layersMap.values()).find(l => l.xref === parentXref); - - const childName = await promptForInput('Add Child Layer', `Enter name for child layer under "${parentLayer?.text || 'Layer'}":`); - - if (!childName || !childName.trim()) return; - - try { - const childXref = currentDoc.addOCGWithParent(childName.trim(), parentXref); - const parentDisplayOrder = parentLayer?.displayOrder || 0; - layersMap.forEach((l) => { - if (l.displayOrder > parentDisplayOrder) { - l.displayOrder += 1; - } - }); - - layersMap.set(childXref, { - number: childXref, - xref: childXref, - text: childName.trim(), - on: true, - locked: false, - depth: (parentLayer?.depth || 0) + 1, - parentXref: parentXref, - displayOrder: parentDisplayOrder + 1 - }); - - renderLayers(); - } catch (err) { - console.error('Failed to add child layer:', err); - showAlert('Error', 'Failed to add child layer'); - } - }); - }); - }; - - const loadLayers = async () => { - if (!currentFile) { - showAlert('No File', 'Please select a PDF file.'); - return; + if (!layer) { + showAlert('Error', 'Layer not found'); + return; } try { - showLoader('Loading engine...'); - await pymupdf.load(); - - showLoader(`Loading layers from ${currentFile.name}...`); - currentDoc = await pymupdf.open(currentFile); - - showLoader('Reading layer configuration...'); - const existingLayers = currentDoc.getLayerConfig(); - - // Reset and populate layers map - layersMap.clear(); - nextDisplayOrder = 0; - - existingLayers.forEach((layer: any) => { - layersMap.set(layer.number, { - number: layer.number, - xref: layer.xref ?? layer.number, - text: layer.text, - on: layer.on, - locked: layer.locked, - depth: layer.depth ?? 0, - parentXref: layer.parentXref ?? 0, - displayOrder: layer.displayOrder ?? nextDisplayOrder++ - }); - if ((layer.displayOrder ?? -1) >= nextDisplayOrder) { - nextDisplayOrder = layer.displayOrder + 1; - } - }); - - hideLoader(); - - // Hide upload zone, show layers container - if (dropZone) dropZone.style.display = 'none'; - if (processBtnContainer) processBtnContainer.classList.add('hidden'); - if (layersContainer) layersContainer.classList.remove('hidden'); - - renderLayers(); - setupLayerHandlers(); - - } catch (error: any) { - hideLoader(); - showAlert('Error', error.message || 'Failed to load PDF layers'); - console.error('Layers error:', error); + currentDoc.deleteOCG(layer.number); + layersMap.delete(layer.number); + renderLayers(); + } catch (err) { + console.error('Failed to delete layer:', err); + showAlert('Error', 'Failed to delete layer'); } - }; + }); + }); - const setupLayerHandlers = () => { - const addLayerBtn = document.getElementById('add-layer-btn'); - const newLayerInput = document.getElementById('new-layer-name') as HTMLInputElement; - const saveLayersBtn = document.getElementById('save-layers-btn'); + layersList.querySelectorAll('.layer-add-child').forEach((btn) => { + btn.addEventListener('click', async (e) => { + const target = e.target as HTMLButtonElement; + const parentXref = parseInt(target.dataset.xref || '0'); + const parentLayer = Array.from(layersMap.values()).find( + (l) => l.xref === parentXref + ); - if (addLayerBtn && newLayerInput) { - addLayerBtn.onclick = () => { - const name = newLayerInput.value.trim(); - if (!name) { - showAlert('Invalid Name', 'Please enter a layer name'); - return; - } + const childName = await promptForInput( + 'Add Child Layer', + `Enter name for child layer under "${parentLayer?.text || 'Layer'}":` + ); - try { - const xref = currentDoc.addOCG(name); - newLayerInput.value = ''; + if (!childName || !childName.trim()) return; - const newDisplayOrder = nextDisplayOrder++; - layersMap.set(xref, { - number: xref, - xref: xref, - text: name, - on: true, - locked: false, - depth: 0, - parentXref: 0, - displayOrder: newDisplayOrder - }); - - renderLayers(); - } catch (err: any) { - showAlert('Error', 'Failed to add layer: ' + err.message); - } - }; - } - - if (saveLayersBtn) { - saveLayersBtn.onclick = () => { - try { - showLoader('Saving PDF with layer changes...'); - const pdfBytes = currentDoc.save(); - const blob = new Blob([new Uint8Array(pdfBytes)], { type: 'application/pdf' }); - const outName = currentFile!.name.replace(/\.pdf$/i, '') + '_layers.pdf'; - downloadFile(blob, outName); - hideLoader(); - resetState(); - showAlert('Success', 'PDF with layer changes saved!', 'success'); - } catch (err: any) { - hideLoader(); - showAlert('Error', 'Failed to save PDF: ' + err.message); - } - }; - } - }; - - const handleFileSelect = (files: FileList | null) => { - if (files && files.length > 0) { - const file = files[0]; - if (file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf')) { - currentFile = file; - updateUI(); - } else { - showAlert('Invalid File', 'Please select a PDF file.'); + try { + const childXref = currentDoc.addOCGWithParent( + childName.trim(), + parentXref + ); + const parentDisplayOrder = parentLayer?.displayOrder || 0; + layersMap.forEach((l) => { + if (l.displayOrder > parentDisplayOrder) { + l.displayOrder += 1; } + }); + + layersMap.set(childXref, { + number: childXref, + xref: childXref, + text: childName.trim(), + on: true, + locked: false, + depth: (parentLayer?.depth || 0) + 1, + parentXref: parentXref, + displayOrder: parentDisplayOrder + 1, + }); + + renderLayers(); + } catch (err) { + console.error('Failed to add child layer:', err); + showAlert('Error', 'Failed to add child layer'); } - }; + }); + }); + }; - if (fileInput && dropZone) { - fileInput.addEventListener('change', (e) => { - handleFileSelect((e.target as HTMLInputElement).files); - }); - - dropZone.addEventListener('dragover', (e) => { - e.preventDefault(); - dropZone.classList.add('bg-gray-700'); - }); - - dropZone.addEventListener('dragleave', (e) => { - e.preventDefault(); - dropZone.classList.remove('bg-gray-700'); - }); - - dropZone.addEventListener('drop', (e) => { - e.preventDefault(); - dropZone.classList.remove('bg-gray-700'); - handleFileSelect(e.dataTransfer?.files ?? null); - }); - - fileInput.addEventListener('click', () => { - fileInput.value = ''; - }); + const loadLayers = async () => { + if (!currentFile) { + showAlert('No File', 'Please select a PDF file.'); + return; } - if (processBtn) { - processBtn.addEventListener('click', loadLayers); + try { + showLoader('Loading engine...'); + const pymupdf = await loadPyMuPDF(); + + showLoader(`Loading layers from ${currentFile.name}...`); + currentDoc = await pymupdf.open(currentFile); + + showLoader('Reading layer configuration...'); + const existingLayers = currentDoc.getLayerConfig(); + + // Reset and populate layers map + layersMap.clear(); + nextDisplayOrder = 0; + + existingLayers.forEach((layer: any) => { + layersMap.set(layer.number, { + number: layer.number, + xref: layer.xref ?? layer.number, + text: layer.text, + on: layer.on, + locked: layer.locked, + depth: layer.depth ?? 0, + parentXref: layer.parentXref ?? 0, + displayOrder: layer.displayOrder ?? nextDisplayOrder++, + }); + if ((layer.displayOrder ?? -1) >= nextDisplayOrder) { + nextDisplayOrder = layer.displayOrder + 1; + } + }); + + hideLoader(); + + // Hide upload zone, show layers container + if (dropZone) dropZone.style.display = 'none'; + if (processBtnContainer) processBtnContainer.classList.add('hidden'); + if (layersContainer) layersContainer.classList.remove('hidden'); + + renderLayers(); + setupLayerHandlers(); + } catch (error: any) { + hideLoader(); + showAlert('Error', error.message || 'Failed to load PDF layers'); + console.error('Layers error:', error); } + }; + + const setupLayerHandlers = () => { + const addLayerBtn = document.getElementById('add-layer-btn'); + const newLayerInput = document.getElementById( + 'new-layer-name' + ) as HTMLInputElement; + const saveLayersBtn = document.getElementById('save-layers-btn'); + + if (addLayerBtn && newLayerInput) { + addLayerBtn.onclick = () => { + const name = newLayerInput.value.trim(); + if (!name) { + showAlert('Invalid Name', 'Please enter a layer name'); + return; + } + + try { + const xref = currentDoc.addOCG(name); + newLayerInput.value = ''; + + const newDisplayOrder = nextDisplayOrder++; + layersMap.set(xref, { + number: xref, + xref: xref, + text: name, + on: true, + locked: false, + depth: 0, + parentXref: 0, + displayOrder: newDisplayOrder, + }); + + renderLayers(); + } catch (err: any) { + showAlert('Error', 'Failed to add layer: ' + err.message); + } + }; + } + + if (saveLayersBtn) { + saveLayersBtn.onclick = () => { + try { + showLoader('Saving PDF with layer changes...'); + const pdfBytes = currentDoc.save(); + const blob = new Blob([new Uint8Array(pdfBytes)], { + type: 'application/pdf', + }); + const outName = + currentFile!.name.replace(/\.pdf$/i, '') + '_layers.pdf'; + downloadFile(blob, outName); + hideLoader(); + resetState(); + showAlert('Success', 'PDF with layer changes saved!', 'success'); + } catch (err: any) { + hideLoader(); + showAlert('Error', 'Failed to save PDF: ' + err.message); + } + }; + } + }; + + const handleFileSelect = (files: FileList | null) => { + if (files && files.length > 0) { + const file = files[0]; + if ( + file.type === 'application/pdf' || + file.name.toLowerCase().endsWith('.pdf') + ) { + currentFile = file; + updateUI(); + } else { + showAlert('Invalid File', 'Please select a PDF file.'); + } + } + }; + + if (fileInput && dropZone) { + fileInput.addEventListener('change', (e) => { + handleFileSelect((e.target as HTMLInputElement).files); + }); + + dropZone.addEventListener('dragover', (e) => { + e.preventDefault(); + dropZone.classList.add('bg-gray-700'); + }); + + dropZone.addEventListener('dragleave', (e) => { + e.preventDefault(); + dropZone.classList.remove('bg-gray-700'); + }); + + dropZone.addEventListener('drop', (e) => { + e.preventDefault(); + dropZone.classList.remove('bg-gray-700'); + handleFileSelect(e.dataTransfer?.files ?? null); + }); + + fileInput.addEventListener('click', () => { + fileInput.value = ''; + }); + } + + if (processBtn) { + processBtn.addEventListener('click', loadLayers); + } }); diff --git a/src/js/logic/pdf-multi-tool.ts b/src/js/logic/pdf-multi-tool.ts index 92c4e7d..4515f06 100644 --- a/src/js/logic/pdf-multi-tool.ts +++ b/src/js/logic/pdf-multi-tool.ts @@ -1,10 +1,15 @@ import { createIcons, icons } from 'lucide'; -import { degrees, PDFDocument as PDFLibDocument } from 'pdf-lib'; +import { degrees, PDFDocument as PDFLibDocument, PDFPage } from 'pdf-lib'; import * as pdfjsLib from 'pdfjs-dist'; import JSZip from 'jszip'; import Sortable from 'sortablejs'; import { downloadFile, getPDFDocument } from '../utils/helpers'; -import { renderPagesProgressively, cleanupLazyRendering, renderPageToCanvas, createPlaceholder } from '../utils/render-utils'; +import { + renderPagesProgressively, + cleanupLazyRendering, + renderPageToCanvas, + createPlaceholder, +} from '../utils/render-utils'; import { initializeGlobalShortcuts } from '../utils/shortcuts-init.js'; import { repairPdfFile } from './repair-pdf.js'; @@ -41,13 +46,17 @@ let sortableInstance: Sortable | null = null; const pageCanvasCache = new Map(); -type Snapshot = { allPages: PageData[]; selectedPages: number[]; splitMarkers: number[] }; +type Snapshot = { + allPages: PageData[]; + selectedPages: number[]; + splitMarkers: number[]; +}; const undoStack: Snapshot[] = []; const redoStack: Snapshot[] = []; function snapshot() { const snap: Snapshot = { - allPages: allPages.map(p => ({ ...p, canvas: p.canvas })), + allPages: allPages.map((p) => ({ ...p, canvas: p.canvas })), selectedPages: Array.from(selectedPages), splitMarkers: Array.from(splitMarkers), }; @@ -56,16 +65,20 @@ function snapshot() { } function restore(snap: Snapshot) { - allPages = snap.allPages.map(p => ({ + allPages = snap.allPages.map((p) => ({ ...p, - canvas: p.canvas + canvas: p.canvas, })); selectedPages = new Set(snap.selectedPages); splitMarkers = new Set(snap.splitMarkers); updatePageDisplay(); } -function showModal(title: string, message: string, type: 'info' | 'error' | 'success' = 'info') { +function showModal( + title: string, + message: string, + type: 'info' | 'error' | 'success' = 'info' +) { const modal = document.getElementById('modal'); const modalTitle = document.getElementById('modal-title'); const modalMessage = document.getElementById('modal-message'); @@ -79,12 +92,12 @@ function showModal(title: string, message: string, type: 'info' | 'error' | 'suc const iconMap = { info: 'info', error: 'alert-circle', - success: 'check-circle' + success: 'check-circle', }; const colorMap = { info: 'text-blue-400', error: 'text-red-400', - success: 'text-green-400' + success: 'text-green-400', }; modalIcon.innerHTML = ``; @@ -112,7 +125,10 @@ function showLoading(current: number, total: number) { text.textContent = t('multiTool.renderingPages'); } -async function withButtonLoading(buttonId: string, action: () => Promise) { +async function withButtonLoading( + buttonId: string, + action: () => Promise +) { const button = document.getElementById(buttonId) as HTMLButtonElement; if (!button) return; @@ -122,7 +138,8 @@ async function withButtonLoading(buttonId: string, action: () => Promise) try { button.disabled = true; button.style.pointerEvents = 'none'; - button.innerHTML = ''; + button.innerHTML = + ''; await action(); } finally { @@ -143,7 +160,9 @@ if (document.readyState === 'loading') { initializeTool(); }); } else { - console.log('PDF Multi Tool: DOMContentLoaded already fired, initializing immediately'); + console.log( + 'PDF Multi Tool: DOMContentLoaded already fired, initializing immediately' + ); initializeTool(); } @@ -160,20 +179,30 @@ function initializeTool() { document.getElementById('upload-pdfs-btn')?.addEventListener('click', () => { console.log('Upload button clicked, isRendering:', isRendering); if (isRendering) { - showModal(t('multiTool.pleaseWait'), t('multiTool.pagesRendering'), 'info'); + showModal( + t('multiTool.pleaseWait'), + t('multiTool.pagesRendering'), + 'info' + ); return; } document.getElementById('pdf-file-input')?.click(); }); - document.getElementById('pdf-file-input')?.addEventListener('change', handlePdfUpload); - document.getElementById('insert-pdf-input')?.addEventListener('change', handleInsertPdf); + document + .getElementById('pdf-file-input') + ?.addEventListener('change', handlePdfUpload); + document + .getElementById('insert-pdf-input') + ?.addEventListener('change', handleInsertPdf); - document.getElementById('bulk-rotate-left-btn')?.addEventListener('click', () => { - if (isRendering) return; - snapshot(); - bulkRotate(-90); - }); + document + .getElementById('bulk-rotate-left-btn') + ?.addEventListener('click', () => { + if (isRendering) return; + snapshot(); + bulkRotate(-90); + }); document.getElementById('bulk-rotate-btn')?.addEventListener('click', () => { if (isRendering) return; snapshot(); @@ -184,27 +213,38 @@ function initializeTool() { snapshot(); bulkDelete(); }); - document.getElementById('bulk-duplicate-btn')?.addEventListener('click', () => { - if (isRendering) return; - snapshot(); - bulkDuplicate(); - }); + document + .getElementById('bulk-duplicate-btn') + ?.addEventListener('click', () => { + if (isRendering) return; + snapshot(); + bulkDuplicate(); + }); document.getElementById('bulk-split-btn')?.addEventListener('click', () => { if (isRendering) return; snapshot(); bulkSplit(); }); - document.getElementById('bulk-download-btn')?.addEventListener('click', () => { - if (isRendering) return; - if (isRendering) return; - if (selectedPages.size === 0) { - showModal(t('multiTool.noPagesSelected'), t('multiTool.selectOnePage'), 'info'); - return; - } - withButtonLoading('bulk-download-btn', async () => { - await downloadPagesAsPdf(Array.from(selectedPages).sort((a, b) => a - b), 'selected-pages.pdf'); + document + .getElementById('bulk-download-btn') + ?.addEventListener('click', () => { + if (isRendering) return; + if (isRendering) return; + if (selectedPages.size === 0) { + showModal( + t('multiTool.noPagesSelected'), + t('multiTool.selectOnePage'), + 'info' + ); + return; + } + withButtonLoading('bulk-download-btn', async () => { + await downloadPagesAsPdf( + Array.from(selectedPages).sort((a, b) => a - b), + 'selected-pages.pdf' + ); + }); }); - }); document.getElementById('select-all-btn')?.addEventListener('click', () => { if (isRendering) return; @@ -229,17 +269,19 @@ function initializeTool() { await downloadAll(); }); }); - document.getElementById('add-blank-page-btn')?.addEventListener('click', () => { - if (isRendering) return; - snapshot(); - addBlankPage(); - }); + document + .getElementById('add-blank-page-btn') + ?.addEventListener('click', () => { + if (isRendering) return; + snapshot(); + addBlankPage(); + }); document.getElementById('undo-btn')?.addEventListener('click', () => { if (isRendering) return; const last = undoStack.pop(); if (last) { const current: Snapshot = { - allPages: allPages.map(p => ({ ...p })), + allPages: allPages.map((p) => ({ ...p })), selectedPages: Array.from(selectedPages), splitMarkers: Array.from(splitMarkers), }; @@ -252,7 +294,7 @@ function initializeTool() { const next = redoStack.pop(); if (next) { const current: Snapshot = { - allPages: allPages.map(p => ({ ...p })), + allPages: allPages.map((p) => ({ ...p })), selectedPages: Array.from(selectedPages), splitMarkers: Array.from(splitMarkers), }; @@ -269,7 +311,9 @@ function initializeTool() { } }); - document.getElementById('modal-close-btn')?.addEventListener('click', hideModal); + document + .getElementById('modal-close-btn') + ?.addEventListener('click', hideModal); document.getElementById('modal')?.addEventListener('click', (e) => { if (e.target === document.getElementById('modal')) { hideModal(); @@ -288,7 +332,9 @@ function initializeTool() { uploadArea.addEventListener('drop', (e) => { e.preventDefault(); uploadArea.classList.remove('border-indigo-500'); - const files = Array.from(e.dataTransfer?.files || []).filter(f => f.type === 'application/pdf'); + const files = Array.from(e.dataTransfer?.files || []).filter( + (f) => f.type === 'application/pdf' + ); if (files.length > 0) { loadPdfs(files); } @@ -361,27 +407,38 @@ async function loadPdfs(files: File[]) { try { console.log(`Repairing ${file.name}...`); const loadingText = document.getElementById('loading-text'); - if (loadingText) loadingText.textContent = `Repairing ${file.name}...`; + if (loadingText) + loadingText.textContent = `Repairing ${file.name}...`; const repairedData = await repairPdfFile(file); if (repairedData) { arrayBuffer = repairedData.buffer as ArrayBuffer; console.log(`Successfully repaired ${file.name} before loading.`); } else { - console.warn(`Repair returned null for ${file.name}, using original file.`); + console.warn( + `Repair returned null for ${file.name}, using original file.` + ); arrayBuffer = await file.arrayBuffer(); } } catch (repairError) { - console.warn(`Failed to repair ${file.name}, attempting to load original:`, repairError); + console.warn( + `Failed to repair ${file.name}, attempting to load original:`, + repairError + ); arrayBuffer = await file.arrayBuffer(); } - const pdfDoc = await PDFLibDocument.load(arrayBuffer, { ignoreEncryption: true, throwOnInvalidObject: false }); + const pdfDoc = await PDFLibDocument.load(arrayBuffer, { + ignoreEncryption: true, + throwOnInvalidObject: false, + }); currentPdfDocs.push(pdfDoc); const pdfIndex = currentPdfDocs.length - 1; const pdfBytes = await pdfDoc.save(); - const pdfjsDoc = await getPDFDocument({ data: new Uint8Array(pdfBytes) }).promise; + const pdfjsDoc = await getPDFDocument({ + data: new Uint8Array(pdfBytes), + }).promise; const numPages = pdfjsDoc.numPages; // Pre-fill allPages with placeholders to maintain order/state @@ -425,10 +482,13 @@ async function loadPdfs(files: File[]) { shouldCancel: () => renderCancelled, // Pass cancellation check } ); - } catch (e) { console.error(`Failed to load PDF ${file.name}:`, e); - showModal(t('multiTool.error'), `${t('multiTool.failedToLoad')} ${file.name}.`, 'error'); + showModal( + t('multiTool.error'), + `${t('multiTool.failedToLoad')} ${file.name}.`, + 'error' + ); } } @@ -439,7 +499,9 @@ async function loadPdfs(files: File[]) { } finally { hideLoading(); isRendering = false; - console.log('PDF Multi Tool: Render finished/cancelled, isRendering set to false'); + console.log( + 'PDF Multi Tool: Render finished/cancelled, isRendering set to false' + ); if (renderCancelled) { renderCancelled = false; } @@ -464,7 +526,10 @@ function createPageCard(pageData: PageData, index: number) { } // Modified to return the element instead of appending it -function createPageElement(canvas: HTMLCanvasElement | null, index: number): HTMLElement { +function createPageElement( + canvas: HTMLCanvasElement | null, + index: number +): HTMLElement { const pageData = allPages[index]; if (!pageData) { console.error(`Page data not found for index ${index}`); @@ -472,7 +537,8 @@ function createPageElement(canvas: HTMLCanvasElement | null, index: number): HTM } const card = document.createElement('div'); - card.className = 'bg-gray-800 rounded-lg border-2 border-gray-700 p-2 relative group cursor-move'; + card.className = + 'bg-gray-800 rounded-lg border-2 border-gray-700 p-2 relative group cursor-move'; card.dataset.pageIndex = index.toString(); card.dataset.pageId = pageData.id; // Set ID for reconciliation @@ -490,7 +556,8 @@ function createPageElement(canvas: HTMLCanvasElement | null, index: number): HTM } const preview = document.createElement('div'); - preview.className = 'bg-white rounded mb-2 overflow-hidden w-full flex items-center justify-center relative h-36 sm:h-64'; + preview.className = + 'bg-white rounded mb-2 overflow-hidden w-full flex items-center justify-center relative h-36 sm:h-64'; if (canvas) { const previewCanvas = canvas; @@ -502,7 +569,8 @@ function createPageElement(canvas: HTMLCanvasElement | null, index: number): HTM } else { // Show loading placeholder if canvas is null const loading = document.createElement('div'); - loading.className = 'flex flex-col items-center justify-center text-gray-400'; + loading.className = + 'flex flex-col items-center justify-center text-gray-400'; loading.innerHTML = ` ${t('common.loading')} @@ -518,15 +586,18 @@ function createPageElement(canvas: HTMLCanvasElement | null, index: number): HTM // Actions toolbar const actions = document.createElement('div'); - actions.className = 'flex items-center justify-center gap-1 sm:opacity-0 group-hover:opacity-100 transition-opacity absolute bottom-2 left-0 right-0'; + actions.className = + 'flex items-center justify-center gap-1 sm:opacity-0 group-hover:opacity-100 transition-opacity absolute bottom-2 left-0 right-0'; const actionsInner = document.createElement('div'); - actionsInner.className = 'flex items-center gap-1 bg-gray-900/90 rounded px-2 py-1'; + actionsInner.className = + 'flex items-center gap-1 bg-gray-900/90 rounded px-2 py-1'; actions.appendChild(actionsInner); // Select checkbox const selectBtn = document.createElement('button'); - selectBtn.className = 'absolute top-2 right-2 p-1 rounded bg-gray-900/70 hover:bg-gray-800 z-10'; + selectBtn.className = + 'absolute top-2 right-2 p-1 rounded bg-gray-900/70 hover:bg-gray-800 z-10'; selectBtn.innerHTML = selectedPages.has(index) ? '' : ''; @@ -538,14 +609,16 @@ function createPageElement(canvas: HTMLCanvasElement | null, index: number): HTM // Rotate button const rotateBtn = document.createElement('button'); rotateBtn.className = 'p-1 rounded hover:bg-gray-700'; - rotateBtn.innerHTML = ''; + rotateBtn.innerHTML = + ''; rotateBtn.onclick = (e) => { e.stopPropagation(); rotatePage(index, 90); }; const rotateLeftBtn = document.createElement('button'); rotateLeftBtn.className = 'p-1 rounded hover:bg-gray-700'; - rotateLeftBtn.innerHTML = ''; + rotateLeftBtn.innerHTML = + ''; rotateLeftBtn.onclick = (e) => { e.stopPropagation(); rotatePage(index, -90); @@ -554,7 +627,8 @@ function createPageElement(canvas: HTMLCanvasElement | null, index: number): HTM // Duplicate button const duplicateBtn = document.createElement('button'); duplicateBtn.className = 'p-1 rounded hover:bg-gray-700'; - duplicateBtn.innerHTML = ''; + duplicateBtn.innerHTML = + ''; duplicateBtn.title = t('multiTool.actions.duplicatePage'); duplicateBtn.onclick = (e) => { e.stopPropagation(); @@ -565,7 +639,8 @@ function createPageElement(canvas: HTMLCanvasElement | null, index: number): HTM // Delete button const deleteBtn = document.createElement('button'); deleteBtn.className = 'p-1 rounded hover:bg-gray-700'; - deleteBtn.innerHTML = ''; + deleteBtn.innerHTML = + ''; deleteBtn.title = t('multiTool.actions.deletePage'); deleteBtn.onclick = (e) => { e.stopPropagation(); @@ -576,7 +651,8 @@ function createPageElement(canvas: HTMLCanvasElement | null, index: number): HTM // Insert PDF button const insertBtn = document.createElement('button'); insertBtn.className = 'p-1 rounded hover:bg-gray-700'; - insertBtn.innerHTML = ''; + insertBtn.innerHTML = + ''; insertBtn.title = t('multiTool.actions.insertPdf'); insertBtn.onclick = (e) => { e.stopPropagation(); @@ -587,7 +663,8 @@ function createPageElement(canvas: HTMLCanvasElement | null, index: number): HTM // Split button const splitBtn = document.createElement('button'); splitBtn.className = 'p-1 rounded hover:bg-gray-700'; - splitBtn.innerHTML = ''; + splitBtn.innerHTML = + ''; splitBtn.title = t('multiTool.actions.toggleSplit'); splitBtn.onclick = (e) => { e.stopPropagation(); @@ -596,14 +673,23 @@ function createPageElement(canvas: HTMLCanvasElement | null, index: number): HTM renderSplitMarkers(); }; - actionsInner.append(rotateLeftBtn, rotateBtn, duplicateBtn, insertBtn, splitBtn, deleteBtn); + actionsInner.append( + rotateLeftBtn, + rotateBtn, + duplicateBtn, + insertBtn, + splitBtn, + deleteBtn + ); card.append(preview, info, actions, selectBtn); // Check for split marker if (splitMarkers.has(index)) { const marker = document.createElement('div'); - marker.className = 'split-marker absolute -right-3 top-0 bottom-0 w-6 flex items-center justify-center z-20 pointer-events-none'; - marker.innerHTML = '
'; + marker.className = + 'split-marker absolute -right-3 top-0 bottom-0 w-6 flex items-center justify-center z-20 pointer-events-none'; + marker.innerHTML = + '
'; card.appendChild(marker); } @@ -655,15 +741,19 @@ function toggleSelectOptimized(index: number) { const card = pagesContainer.children[index] as HTMLElement; if (!card) return; - const selectBtn = card.querySelector('button[class*="absolute top-2 right-2"]'); + const selectBtn = card.querySelector( + 'button[class*="absolute top-2 right-2"]' + ); if (!selectBtn) return; if (selectedPages.has(index)) { card.classList.add('border-indigo-500', 'ring-2', 'ring-indigo-500'); - selectBtn.innerHTML = ''; + selectBtn.innerHTML = + ''; } else { card.classList.remove('border-indigo-500', 'ring-2', 'ring-indigo-500'); - selectBtn.innerHTML = ''; + selectBtn.innerHTML = + ''; } createIcons({ icons }); @@ -730,7 +820,7 @@ function deletePage(index: number) { allPages.splice(index, 1); selectedPages.delete(index); const newSelected = new Set(); - selectedPages.forEach(i => { + selectedPages.forEach((i) => { if (i > index) newSelected.add(i - 1); else if (i < index) newSelected.add(i); }); @@ -759,13 +849,17 @@ async function handleInsertPdf(e: Event) { try { const arrayBuffer = await file.arrayBuffer(); - const pdfDoc = await PDFLibDocument.load(arrayBuffer, { ignoreEncryption: true, throwOnInvalidObject: false }); + const pdfDoc = await PDFLibDocument.load(arrayBuffer, { + ignoreEncryption: true, + throwOnInvalidObject: false, + }); currentPdfDocs.push(pdfDoc); const pdfIndex = currentPdfDocs.length - 1; // Load PDF.js document for rendering const pdfBytes = await pdfDoc.save(); - const pdfjsDoc = await getPDFDocument({ data: new Uint8Array(pdfBytes) }).promise; + const pdfjsDoc = await getPDFDocument({ data: new Uint8Array(pdfBytes) }) + .promise; const numPages = pdfjsDoc.numPages; const newPages: PageData[] = []; @@ -802,13 +896,18 @@ async function handleInsertPdf(e: Event) { // Update UI if card exists const pagesContainer = document.getElementById('pages-container'); - const card = pagesContainer?.querySelector(`div[data-page-index="${globalIndex}"]`); + const card = pagesContainer?.querySelector( + `div[data-page-index="${globalIndex}"]` + ); if (card) { - const preview = card.querySelector('.bg-gray-700') || card.querySelector('.bg-white'); + const preview = + card.querySelector('.bg-gray-700') || + card.querySelector('.bg-white'); if (preview) { // Re-create the preview content preview.innerHTML = ''; - preview.className = 'bg-white rounded mb-2 overflow-hidden w-full flex items-center justify-center relative h-36 sm:h-64'; + preview.className = + 'bg-white rounded mb-2 overflow-hidden w-full flex items-center justify-center relative h-36 sm:h-64'; const previewCanvas = canvas; previewCanvas.className = 'max-w-full max-h-full object-contain'; @@ -819,10 +918,13 @@ async function handleInsertPdf(e: Event) { } } } - } catch (e) { console.error('Failed to insert PDF:', e); - showModal('Error', 'Failed to insert PDF. The file may be corrupted.', 'error'); + showModal( + 'Error', + 'Failed to insert PDF. The file may be corrupted.', + 'error' + ); } input.value = ''; @@ -837,13 +939,15 @@ function renderSplitMarkers() { const pagesContainer = document.getElementById('pages-container'); if (!pagesContainer) return; - pagesContainer.querySelectorAll('.split-marker').forEach(m => m.remove()); + pagesContainer.querySelectorAll('.split-marker').forEach((m) => m.remove()); Array.from(pagesContainer.children).forEach((cardEl, i) => { if (splitMarkers.has(i)) { const marker = document.createElement('div'); - marker.className = 'split-marker absolute -right-3 top-0 bottom-0 w-6 flex items-center justify-center z-20 pointer-events-none'; - marker.innerHTML = '
'; + marker.className = + 'split-marker absolute -right-3 top-0 bottom-0 w-6 flex items-center justify-center z-20 pointer-events-none'; + marker.innerHTML = + '
'; (cardEl as HTMLElement).appendChild(marker); } }); @@ -881,7 +985,7 @@ function bulkRotate(delta: number) { return; } - selectedPages.forEach(index => { + selectedPages.forEach((index) => { const pageData = allPages[index]; if (pageData) { // Update state @@ -890,13 +994,15 @@ function bulkRotate(delta: number) { // Update DOM immediately if it exists const pagesContainer = document.getElementById('pages-container'); - const card = pagesContainer?.querySelector(`div[data-page-index="${index}"]`); + const card = pagesContainer?.querySelector( + `div[data-page-index="${index}"]` + ); if (card) { const canvas = card.querySelector('canvas'); if (canvas) { canvas.style.transform = `rotate(${pageData.visualRotation}deg)`; } - // If no canvas (placeholder), the state update is enough. + // If no canvas (placeholder), the state update is enough. // When it eventually renders, createPageElement will use the new rotation. } } @@ -911,7 +1017,7 @@ function bulkDelete() { return; } const indices = Array.from(selectedPages).sort((a, b) => b - a); - indices.forEach(index => allPages.splice(index, 1)); + indices.forEach((index) => allPages.splice(index, 1)); selectedPages.clear(); if (allPages.length === 0) { @@ -928,7 +1034,7 @@ function bulkDuplicate() { return; } const indices = Array.from(selectedPages).sort((a, b) => b - a); - indices.forEach(index => { + indices.forEach((index) => { duplicatePage(index); }); selectedPages.clear(); @@ -937,11 +1043,15 @@ function bulkDuplicate() { function bulkSplit() { if (selectedPages.size === 0) { - showModal('No Selection', 'Please select pages to mark for splitting.', 'info'); + showModal( + 'No Selection', + 'Please select pages to mark for splitting.', + 'info' + ); return; } const indices = Array.from(selectedPages); - indices.forEach(index => { + indices.forEach((index) => { if (!splitMarkers.has(index)) { splitMarkers.add(index); } @@ -951,7 +1061,6 @@ function bulkSplit() { updatePageDisplay(); } - async function downloadAll() { if (allPages.length === 0) { showModal('No Pages', 'Please upload PDFs first.', 'info'); @@ -993,24 +1102,63 @@ async function downloadSplitPdfs() { segments.push(currentSegment); } - // Create PDFs for each segment for (let segIndex = 0; segIndex < segments.length; segIndex++) { const segment = segments[segIndex]; const newPdf = await PDFLibDocument.create(); + const segSpecs: ( + | { + type: 'pdf'; + pdfDoc: PDFLibDocument; + originalPageIndex: number; + rotation: number; + } + | { type: 'blank' } + )[] = []; for (const index of segment) { const pageData = allPages[index]; - if (!pageData) { - console.warn(`Page data missing for index ${index}`); - continue; - } + if (!pageData) continue; if (pageData.pdfDoc && pageData.originalPageIndex >= 0) { - const [copiedPage] = await newPdf.copyPages(pageData.pdfDoc, [pageData.originalPageIndex]); - const page = newPdf.addPage(copiedPage); + segSpecs.push({ + type: 'pdf', + pdfDoc: pageData.pdfDoc, + originalPageIndex: pageData.originalPageIndex, + rotation: pageData.rotation, + }); + } else { + segSpecs.push({ type: 'blank' }); + } + } - if (pageData.rotation !== 0) { + const docPageIndices = new Map(); + for (const spec of segSpecs) { + if (spec.type === 'pdf') { + if (!docPageIndices.has(spec.pdfDoc)) { + docPageIndices.set(spec.pdfDoc, []); + } + docPageIndices.get(spec.pdfDoc)!.push(spec.originalPageIndex); + } + } + + const copiedPagesMap = new Map(); + for (const [doc, pageIdxs] of Array.from(docPageIndices)) { + const copied = await newPdf.copyPages(doc, pageIdxs); + copiedPagesMap.set(doc, copied); + } + + const docConsumeIndex = new Map(); + docPageIndices.forEach((_, doc) => docConsumeIndex.set(doc, 0)); + + for (const spec of segSpecs) { + if (spec.type === 'pdf') { + const idx = docConsumeIndex.get(spec.pdfDoc)!; + const copiedPage = copiedPagesMap.get(spec.pdfDoc)![idx]; + docConsumeIndex.set(spec.pdfDoc, idx + 1); + + const page = newPdf.addPage(copiedPage); + if (spec.rotation !== 0) { const currentRotation = page.getRotation().angle; - page.setRotation(degrees(currentRotation + pageData.rotation)); + page.setRotation(degrees(currentRotation + spec.rotation)); } } else { newPdf.addPage([595, 842]); @@ -1025,7 +1173,11 @@ async function downloadSplitPdfs() { const zipBlob = await zip.generateAsync({ type: 'blob' }); downloadFile(zipBlob, 'split-documents.zip'); - showModal('Success', `Downloaded ${segments.length} PDF files in a ZIP archive.`, 'success'); + showModal( + 'Success', + `Downloaded ${segments.length} PDF files in a ZIP archive.`, + 'success' + ); } catch (e) { console.error('Failed to create split PDFs:', e); showModal('Error', 'Failed to create split PDFs.', 'error'); @@ -1038,20 +1190,59 @@ async function downloadPagesAsPdf(indices: number[], filename: string) { try { const newPdf = await PDFLibDocument.create(); + const pageSpecs: ( + | { + type: 'pdf'; + pdfDoc: PDFLibDocument; + originalPageIndex: number; + rotation: number; + } + | { type: 'blank' } + )[] = []; for (const index of indices) { const pageData = allPages[index]; - if (!pageData) { - console.warn(`Page data missing for index ${index}`); - continue; - } + if (!pageData) continue; if (pageData.pdfDoc && pageData.originalPageIndex >= 0) { - // Copy page from original PDF - const [copiedPage] = await newPdf.copyPages(pageData.pdfDoc, [pageData.originalPageIndex]); - const page = newPdf.addPage(copiedPage); + pageSpecs.push({ + type: 'pdf', + pdfDoc: pageData.pdfDoc, + originalPageIndex: pageData.originalPageIndex, + rotation: pageData.rotation, + }); + } else { + pageSpecs.push({ type: 'blank' }); + } + } - if (pageData.rotation !== 0) { + const docPageIndices = new Map(); + for (const spec of pageSpecs) { + if (spec.type === 'pdf') { + if (!docPageIndices.has(spec.pdfDoc)) { + docPageIndices.set(spec.pdfDoc, []); + } + docPageIndices.get(spec.pdfDoc)!.push(spec.originalPageIndex); + } + } + + const copiedPagesMap = new Map(); + for (const [doc, pageIdxs] of Array.from(docPageIndices)) { + const copied = await newPdf.copyPages(doc, pageIdxs); + copiedPagesMap.set(doc, copied); + } + + const docConsumeIndex = new Map(); + docPageIndices.forEach((_, doc) => docConsumeIndex.set(doc, 0)); + + for (const spec of pageSpecs) { + if (spec.type === 'pdf') { + const idx = docConsumeIndex.get(spec.pdfDoc)!; + const copiedPage = copiedPagesMap.get(spec.pdfDoc)![idx]; + docConsumeIndex.set(spec.pdfDoc, idx + 1); + + const page = newPdf.addPage(copiedPage); + if (spec.rotation !== 0) { const currentRotation = page.getRotation().angle; - page.setRotation(degrees(currentRotation + pageData.rotation)); + page.setRotation(degrees(currentRotation + spec.rotation)); } } else { newPdf.addPage([595, 842]); @@ -1059,7 +1250,9 @@ async function downloadPagesAsPdf(indices: number[], filename: string) { } const pdfBytes = await newPdf.save(); - const blob = new Blob([new Uint8Array(pdfBytes)], { type: 'application/pdf' }); + const blob = new Blob([new Uint8Array(pdfBytes)], { + type: 'application/pdf', + }); downloadFile(blob, filename); showModal('Success', 'PDF downloaded successfully.', 'success'); @@ -1101,18 +1294,28 @@ function updatePageDisplay() { // Update index-dependent attributes card.dataset.pageIndex = index.toString(); - const info = card.querySelector('.text-xs.text-gray-400.text-center.mb-2'); + const info = card.querySelector( + '.text-xs.text-gray-400.text-center.mb-2' + ); if (info) info.textContent = `Page ${index + 1} `; // Update selection state - const selectBtn = card.querySelector('button[class*="absolute top-2 right-2"]'); + const selectBtn = card.querySelector( + 'button[class*="absolute top-2 right-2"]' + ); if (selectBtn) { if (selectedPages.has(index)) { card.classList.add('border-indigo-500', 'ring-2', 'ring-indigo-500'); - selectBtn.innerHTML = ''; + selectBtn.innerHTML = + ''; } else { - card.classList.remove('border-indigo-500', 'ring-2', 'ring-indigo-500'); - selectBtn.innerHTML = ''; + card.classList.remove( + 'border-indigo-500', + 'ring-2', + 'ring-indigo-500' + ); + selectBtn.innerHTML = + ''; } // Update click handler to use new index (selectBtn as HTMLElement).onclick = (e) => { @@ -1128,17 +1331,47 @@ function updatePageDisplay() { } // Update action buttons - const actionsInner = card.querySelector('.flex.items-center.gap-1.bg-gray-900\\/90'); + const actionsInner = card.querySelector( + '.flex.items-center.gap-1.bg-gray-900\\/90' + ); if (actionsInner) { const buttons = actionsInner.querySelectorAll('button'); - if (buttons[0]) (buttons[0] as HTMLElement).onclick = (e) => { e.stopPropagation(); rotatePage(index, -90); }; - if (buttons[1]) (buttons[1] as HTMLElement).onclick = (e) => { e.stopPropagation(); rotatePage(index, 90); }; - if (buttons[2]) (buttons[2] as HTMLElement).onclick = (e) => { e.stopPropagation(); snapshot(); duplicatePage(index); }; - if (buttons[3]) (buttons[3] as HTMLElement).onclick = (e) => { e.stopPropagation(); snapshot(); insertPdfAfter(index); }; - if (buttons[4]) (buttons[4] as HTMLElement).onclick = (e) => { e.stopPropagation(); snapshot(); toggleSplitMarker(index); renderSplitMarkers(); }; - if (buttons[5]) (buttons[5] as HTMLElement).onclick = (e) => { e.stopPropagation(); snapshot(); deletePage(index); }; + if (buttons[0]) + (buttons[0] as HTMLElement).onclick = (e) => { + e.stopPropagation(); + rotatePage(index, -90); + }; + if (buttons[1]) + (buttons[1] as HTMLElement).onclick = (e) => { + e.stopPropagation(); + rotatePage(index, 90); + }; + if (buttons[2]) + (buttons[2] as HTMLElement).onclick = (e) => { + e.stopPropagation(); + snapshot(); + duplicatePage(index); + }; + if (buttons[3]) + (buttons[3] as HTMLElement).onclick = (e) => { + e.stopPropagation(); + snapshot(); + insertPdfAfter(index); + }; + if (buttons[4]) + (buttons[4] as HTMLElement).onclick = (e) => { + e.stopPropagation(); + snapshot(); + toggleSplitMarker(index); + renderSplitMarkers(); + }; + if (buttons[5]) + (buttons[5] as HTMLElement).onclick = (e) => { + e.stopPropagation(); + snapshot(); + deletePage(index); + }; } - } else { // Element doesn't exist, create it card = createPageElement(pageData.canvas, index); @@ -1179,7 +1412,9 @@ function updatePageNumbers() { // We need to find the buttons and update their onclick handlers // This is necessary because the original handlers captured the old index - const selectBtn = card.querySelector('button[class*="absolute top-2 right-2"]') as HTMLButtonElement; + const selectBtn = card.querySelector( + 'button[class*="absolute top-2 right-2"]' + ) as HTMLButtonElement; if (selectBtn) { selectBtn.onclick = (e) => { e.stopPropagation(); @@ -1187,16 +1422,47 @@ function updatePageNumbers() { }; } - const actionsInner = card.querySelector('.flex.items-center.gap-1.bg-gray-900\\/90'); + const actionsInner = card.querySelector( + '.flex.items-center.gap-1.bg-gray-900\\/90' + ); if (actionsInner) { const buttons = actionsInner.querySelectorAll('button'); // Order: Rotate Left, Rotate Right, Duplicate, Insert, Split, Delete - if (buttons[0]) buttons[0].onclick = (e) => { e.stopPropagation(); rotatePage(index, -90); }; - if (buttons[1]) buttons[1].onclick = (e) => { e.stopPropagation(); rotatePage(index, 90); }; - if (buttons[2]) buttons[2].onclick = (e) => { e.stopPropagation(); snapshot(); duplicatePage(index); }; - if (buttons[3]) buttons[3].onclick = (e) => { e.stopPropagation(); snapshot(); insertPdfAfter(index); }; - if (buttons[4]) buttons[4].onclick = (e) => { e.stopPropagation(); snapshot(); toggleSplitMarker(index); renderSplitMarkers(); }; - if (buttons[5]) buttons[5].onclick = (e) => { e.stopPropagation(); snapshot(); deletePage(index); }; + if (buttons[0]) + buttons[0].onclick = (e) => { + e.stopPropagation(); + rotatePage(index, -90); + }; + if (buttons[1]) + buttons[1].onclick = (e) => { + e.stopPropagation(); + rotatePage(index, 90); + }; + if (buttons[2]) + buttons[2].onclick = (e) => { + e.stopPropagation(); + snapshot(); + duplicatePage(index); + }; + if (buttons[3]) + buttons[3].onclick = (e) => { + e.stopPropagation(); + snapshot(); + insertPdfAfter(index); + }; + if (buttons[4]) + buttons[4].onclick = (e) => { + e.stopPropagation(); + snapshot(); + toggleSplitMarker(index); + renderSplitMarkers(); + }; + if (buttons[5]) + buttons[5].onclick = (e) => { + e.stopPropagation(); + snapshot(); + deletePage(index); + }; } }); -} \ No newline at end of file +} diff --git a/src/js/logic/pdf-to-bmp-page.ts b/src/js/logic/pdf-to-bmp-page.ts index d165851..1f71b77 100644 --- a/src/js/logic/pdf-to-bmp-page.ts +++ b/src/js/logic/pdf-to-bmp-page.ts @@ -1,179 +1,214 @@ import { showLoader, hideLoader, showAlert } from '../ui.js'; -import { downloadFile, formatBytes, readFileAsArrayBuffer, getPDFDocument } from '../utils/helpers.js'; +import { + downloadFile, + formatBytes, + readFileAsArrayBuffer, + getPDFDocument, + getCleanPdfFilename, +} from '../utils/helpers.js'; import { createIcons, icons } from 'lucide'; import JSZip from 'jszip'; import * as pdfjsLib from 'pdfjs-dist'; +import { PDFPageProxy } from 'pdfjs-dist'; -pdfjsLib.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).toString(); +pdfjsLib.GlobalWorkerOptions.workerSrc = new URL( + 'pdfjs-dist/build/pdf.worker.min.mjs', + import.meta.url +).toString(); let files: File[] = []; const updateUI = () => { - const fileDisplayArea = document.getElementById('file-display-area'); - const optionsPanel = document.getElementById('options-panel'); - const dropZone = document.getElementById('drop-zone'); + const fileDisplayArea = document.getElementById('file-display-area'); + const optionsPanel = document.getElementById('options-panel'); + const dropZone = document.getElementById('drop-zone'); - if (!fileDisplayArea || !optionsPanel || !dropZone) return; + if (!fileDisplayArea || !optionsPanel || !dropZone) return; - fileDisplayArea.innerHTML = ''; + fileDisplayArea.innerHTML = ''; - if (files.length > 0) { - optionsPanel.classList.remove('hidden'); + if (files.length > 0) { + optionsPanel.classList.remove('hidden'); - files.forEach((file) => { - const fileDiv = document.createElement('div'); - fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm'; + files.forEach((file) => { + const fileDiv = document.createElement('div'); + fileDiv.className = + 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm'; - const infoContainer = document.createElement('div'); - infoContainer.className = 'flex flex-col overflow-hidden'; + const infoContainer = document.createElement('div'); + infoContainer.className = 'flex flex-col overflow-hidden'; - const nameSpan = document.createElement('div'); - nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1'; - nameSpan.textContent = file.name; + const nameSpan = document.createElement('div'); + nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1'; + nameSpan.textContent = file.name; - const metaSpan = document.createElement('div'); - metaSpan.className = 'text-xs text-gray-400'; - metaSpan.textContent = `${formatBytes(file.size)} • Loading pages...`; // Initial state + const metaSpan = document.createElement('div'); + metaSpan.className = 'text-xs text-gray-400'; + metaSpan.textContent = `${formatBytes(file.size)} • Loading pages...`; // Initial state - infoContainer.append(nameSpan, metaSpan); + infoContainer.append(nameSpan, metaSpan); - // Add remove button - const removeBtn = document.createElement('button'); - removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0'; - removeBtn.innerHTML = ''; - removeBtn.onclick = () => { - files = []; - updateUI(); - }; + // Add remove button + const removeBtn = document.createElement('button'); + removeBtn.className = + 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0'; + removeBtn.innerHTML = ''; + removeBtn.onclick = () => { + files = []; + updateUI(); + }; - fileDiv.append(infoContainer, removeBtn); - fileDisplayArea.appendChild(fileDiv); + fileDiv.append(infoContainer, removeBtn); + fileDisplayArea.appendChild(fileDiv); - // Fetch page count asynchronously - readFileAsArrayBuffer(file).then(buffer => { - return getPDFDocument(buffer).promise; - }).then(pdf => { - metaSpan.textContent = `${formatBytes(file.size)} • ${pdf.numPages} page${pdf.numPages !== 1 ? 's' : ''}`; - }).catch(e => { - console.warn('Error loading PDF page count:', e); - metaSpan.textContent = formatBytes(file.size); - }); + // Fetch page count asynchronously + readFileAsArrayBuffer(file) + .then((buffer) => { + return getPDFDocument(buffer).promise; + }) + .then((pdf) => { + metaSpan.textContent = `${formatBytes(file.size)} • ${pdf.numPages} page${pdf.numPages !== 1 ? 's' : ''}`; + }) + .catch((e) => { + console.warn('Error loading PDF page count:', e); + metaSpan.textContent = formatBytes(file.size); }); + }); - // Initialize icons immediately after synchronous render - createIcons({ icons }); - } else { - optionsPanel.classList.add('hidden'); - } + // Initialize icons immediately after synchronous render + createIcons({ icons }); + } else { + optionsPanel.classList.add('hidden'); + } }; const resetState = () => { - files = []; - const fileInput = document.getElementById('file-input') as HTMLInputElement; - if (fileInput) fileInput.value = ''; - updateUI(); + files = []; + const fileInput = document.getElementById('file-input') as HTMLInputElement; + if (fileInput) fileInput.value = ''; + updateUI(); }; async function convert() { - if (files.length === 0) { - showAlert('No File', 'Please upload a PDF file first.'); - return; - } - showLoader('Converting to BMP...'); - try { - const pdf = await getPDFDocument( - await readFileAsArrayBuffer(files[0]) - ).promise; - const zip = new JSZip(); + if (files.length === 0) { + showAlert('No File', 'Please upload a PDF file first.'); + return; + } + showLoader('Converting to BMP...'); + try { + const pdf = await getPDFDocument(await readFileAsArrayBuffer(files[0])) + .promise; - for (let i = 1; i <= pdf.numPages; i++) { - const page = await pdf.getPage(i); - const viewport = page.getViewport({ scale: 2.0 }); - const canvas = document.createElement('canvas'); - const context = canvas.getContext('2d'); - canvas.height = viewport.height; - canvas.width = viewport.width; - - await page.render({ canvasContext: context!, viewport: viewport, canvas }).promise; - - const blob = await new Promise((resolve) => - canvas.toBlob(resolve, 'image/bmp') - ); - if (blob) { - zip.file(`page_${i}.bmp`, blob); - } + if (pdf.numPages === 1) { + const page = await pdf.getPage(1); + const blob = await renderPage(page); + downloadFile(blob, getCleanPdfFilename(files[0].name) + '.bmp'); + } else { + const zip = new JSZip(); + for (let i = 1; i <= pdf.numPages; i++) { + const page = await pdf.getPage(i); + const blob = await renderPage(page); + if (blob) { + zip.file(`page_${i}.bmp`, blob); } + } - const zipBlob = await zip.generateAsync({ type: 'blob' }); - downloadFile(zipBlob, 'converted_images.zip'); - showAlert('Success', 'PDF converted to BMPs successfully!', 'success', () => { - resetState(); - }); - } catch (e) { - console.error(e); - showAlert( - 'Error', - 'Failed to convert PDF to BMP. The file might be corrupted.' - ); - } finally { - hideLoader(); + const zipBlob = await zip.generateAsync({ type: 'blob' }); + downloadFile(zipBlob, getCleanPdfFilename(files[0].name) + '_bmps.zip'); } + + showAlert( + 'Success', + 'PDF converted to BMPs successfully!', + 'success', + () => { + resetState(); + } + ); + } catch (e) { + console.error(e); + showAlert( + 'Error', + 'Failed to convert PDF to BMP. The file might be corrupted.' + ); + } finally { + hideLoader(); + } +} + +async function renderPage(page: PDFPageProxy): Promise { + const viewport = page.getViewport({ scale: 2.0 }); + const canvas = document.createElement('canvas'); + const context = canvas.getContext('2d'); + canvas.height = viewport.height; + canvas.width = viewport.width; + + await page.render({ + canvasContext: context!, + viewport: viewport, + canvas, + }).promise; + + const blob = await new Promise((resolve) => + canvas.toBlob(resolve, 'image/bmp') + ); + return blob; } document.addEventListener('DOMContentLoaded', () => { - const fileInput = document.getElementById('file-input') as HTMLInputElement; - const dropZone = document.getElementById('drop-zone'); - const processBtn = document.getElementById('process-btn'); - const backBtn = document.getElementById('back-to-tools'); + const fileInput = document.getElementById('file-input') as HTMLInputElement; + const dropZone = document.getElementById('drop-zone'); + const processBtn = document.getElementById('process-btn'); + const backBtn = document.getElementById('back-to-tools'); - if (backBtn) { - backBtn.addEventListener('click', () => { - window.location.href = import.meta.env.BASE_URL; - }); + if (backBtn) { + backBtn.addEventListener('click', () => { + window.location.href = import.meta.env.BASE_URL; + }); + } + + const handleFileSelect = (newFiles: FileList | null) => { + if (!newFiles || newFiles.length === 0) return; + const validFiles = Array.from(newFiles).filter( + (file) => file.type === 'application/pdf' + ); + + if (validFiles.length === 0) { + showAlert('Invalid File', 'Please upload a PDF file.'); + return; } - const handleFileSelect = (newFiles: FileList | null) => { - if (!newFiles || newFiles.length === 0) return; - const validFiles = Array.from(newFiles).filter( - (file) => file.type === 'application/pdf' - ); + files = [validFiles[0]]; + updateUI(); + }; - if (validFiles.length === 0) { - showAlert('Invalid File', 'Please upload a PDF file.'); - return; - } + if (fileInput && dropZone) { + fileInput.addEventListener('change', (e) => { + handleFileSelect((e.target as HTMLInputElement).files); + }); - files = [validFiles[0]]; - updateUI(); - }; + dropZone.addEventListener('dragover', (e) => { + e.preventDefault(); + dropZone.classList.add('bg-gray-700'); + }); - if (fileInput && dropZone) { - fileInput.addEventListener('change', (e) => { - handleFileSelect((e.target as HTMLInputElement).files); - }); + dropZone.addEventListener('dragleave', (e) => { + e.preventDefault(); + dropZone.classList.remove('bg-gray-700'); + }); - dropZone.addEventListener('dragover', (e) => { - e.preventDefault(); - dropZone.classList.add('bg-gray-700'); - }); + dropZone.addEventListener('drop', (e) => { + e.preventDefault(); + dropZone.classList.remove('bg-gray-700'); + handleFileSelect(e.dataTransfer?.files ?? null); + }); - dropZone.addEventListener('dragleave', (e) => { - e.preventDefault(); - dropZone.classList.remove('bg-gray-700'); - }); + fileInput.addEventListener('click', () => { + fileInput.value = ''; + }); + } - dropZone.addEventListener('drop', (e) => { - e.preventDefault(); - dropZone.classList.remove('bg-gray-700'); - handleFileSelect(e.dataTransfer?.files ?? null); - }); - - fileInput.addEventListener('click', () => { - fileInput.value = ''; - }); - } - - if (processBtn) { - processBtn.addEventListener('click', convert); - } + if (processBtn) { + processBtn.addEventListener('click', convert); + } }); diff --git a/src/js/logic/pdf-to-csv-page.ts b/src/js/logic/pdf-to-csv-page.ts index 3415fe2..ef94b8c 100644 --- a/src/js/logic/pdf-to-csv-page.ts +++ b/src/js/logic/pdf-to-csv-page.ts @@ -2,171 +2,186 @@ import { showLoader, hideLoader, showAlert } from '../ui.js'; import { downloadFile, formatBytes } from '../utils/helpers.js'; import { createIcons, icons } from 'lucide'; import JSZip from 'jszip'; -import { PyMuPDF } from '@bentopdf/pymupdf-wasm'; -import { getWasmBaseUrl } from '../config/wasm-cdn-config.js'; - -const pymupdf = new PyMuPDF(getWasmBaseUrl('pymupdf')); +import { isWasmAvailable, getWasmBaseUrl } from '../config/wasm-cdn-config.js'; +import { showWasmRequiredDialog } from '../utils/wasm-provider.js'; +import { loadPyMuPDF, isPyMuPDFAvailable } from '../utils/pymupdf-loader.js'; let file: File | null = null; const updateUI = () => { - const fileDisplayArea = document.getElementById('file-display-area'); - const optionsPanel = document.getElementById('options-panel'); + const fileDisplayArea = document.getElementById('file-display-area'); + const optionsPanel = document.getElementById('options-panel'); - if (!fileDisplayArea || !optionsPanel) return; + if (!fileDisplayArea || !optionsPanel) return; - fileDisplayArea.innerHTML = ''; + fileDisplayArea.innerHTML = ''; - if (file) { - optionsPanel.classList.remove('hidden'); + if (file) { + optionsPanel.classList.remove('hidden'); - const fileDiv = document.createElement('div'); - fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm'; + const fileDiv = document.createElement('div'); + fileDiv.className = + 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm'; - const infoContainer = document.createElement('div'); - infoContainer.className = 'flex flex-col overflow-hidden'; + const infoContainer = document.createElement('div'); + infoContainer.className = 'flex flex-col overflow-hidden'; - const nameSpan = document.createElement('div'); - nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1'; - nameSpan.textContent = file.name; + const nameSpan = document.createElement('div'); + nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1'; + nameSpan.textContent = file.name; - const metaSpan = document.createElement('div'); - metaSpan.className = 'text-xs text-gray-400'; - metaSpan.textContent = formatBytes(file.size); + const metaSpan = document.createElement('div'); + metaSpan.className = 'text-xs text-gray-400'; + metaSpan.textContent = formatBytes(file.size); - infoContainer.append(nameSpan, metaSpan); + infoContainer.append(nameSpan, metaSpan); - const removeBtn = document.createElement('button'); - removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0'; - removeBtn.innerHTML = ''; - removeBtn.onclick = resetState; + 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); + fileDiv.append(infoContainer, removeBtn); + fileDisplayArea.appendChild(fileDiv); - createIcons({ icons }); - } else { - optionsPanel.classList.add('hidden'); - } + createIcons({ icons }); + } else { + optionsPanel.classList.add('hidden'); + } }; const resetState = () => { - file = null; - const fileInput = document.getElementById('file-input') as HTMLInputElement; - if (fileInput) fileInput.value = ''; - updateUI(); + file = null; + const fileInput = document.getElementById('file-input') as HTMLInputElement; + if (fileInput) fileInput.value = ''; + updateUI(); }; function tableToCsv(rows: (string | null)[][]): string { - return rows.map(row => - row.map(cell => { - const cellStr = cell ?? ''; - if (cellStr.includes(',') || cellStr.includes('"') || cellStr.includes('\n')) { - return `"${cellStr.replace(/"/g, '""')}"`; - } - return cellStr; - }).join(',') - ).join('\n'); + return rows + .map((row) => + row + .map((cell) => { + const cellStr = cell ?? ''; + if ( + cellStr.includes(',') || + cellStr.includes('"') || + cellStr.includes('\n') + ) { + return `"${cellStr.replace(/"/g, '""')}"`; + } + return cellStr; + }) + .join(',') + ) + .join('\n'); } async function convert() { - if (!file) { - showAlert('No File', 'Please upload a PDF file first.'); - return; + if (!file) { + showAlert('No File', 'Please upload a PDF file first.'); + return; + } + + showLoader('Loading Engine...'); + + try { + const pymupdf = await loadPyMuPDF(); + showLoader('Extracting tables...'); + + const doc = await pymupdf.open(file); + const pageCount = doc.pageCount; + const baseName = file.name.replace(/\.[^/.]+$/, ''); + + const allRows: (string | null)[][] = []; + + for (let i = 0; i < pageCount; i++) { + showLoader(`Scanning page ${i + 1} of ${pageCount}...`); + const page = doc.getPage(i); + const tables = page.findTables(); + + tables.forEach((table) => { + allRows.push(...table.rows); + allRows.push([]); + }); } - showLoader('Loading Engine...'); - - try { - await pymupdf.load(); - showLoader('Extracting tables...'); - - const doc = await pymupdf.open(file); - const pageCount = doc.pageCount; - const baseName = file.name.replace(/\.[^/.]+$/, ''); - - const allRows: (string | null)[][] = []; - - for (let i = 0; i < pageCount; i++) { - showLoader(`Scanning page ${i + 1} of ${pageCount}...`); - const page = doc.getPage(i); - const tables = page.findTables(); - - tables.forEach((table) => { - allRows.push(...table.rows); - allRows.push([]); - }); - } - - if (allRows.length === 0) { - showAlert('No Tables Found', 'No tables were detected in this PDF.'); - return; - } - - const csvContent = tableToCsv(allRows.filter(row => row.length > 0)); - const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); - downloadFile(blob, `${baseName}.csv`); - showAlert('Success', 'PDF converted to CSV successfully!', 'success', resetState); - } catch (e) { - console.error(e); - const message = e instanceof Error ? e.message : 'Unknown error'; - showAlert('Error', `Failed to convert PDF to CSV. ${message}`); - } finally { - hideLoader(); + if (allRows.length === 0) { + showAlert('No Tables Found', 'No tables were detected in this PDF.'); + return; } + + const csvContent = tableToCsv(allRows.filter((row) => row.length > 0)); + const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); + downloadFile(blob, `${baseName}.csv`); + showAlert( + 'Success', + 'PDF converted to CSV successfully!', + 'success', + resetState + ); + } catch (e) { + console.error(e); + const message = e instanceof Error ? e.message : 'Unknown error'; + showAlert('Error', `Failed to convert PDF to CSV. ${message}`); + } finally { + hideLoader(); + } } document.addEventListener('DOMContentLoaded', () => { - const fileInput = document.getElementById('file-input') as HTMLInputElement; - const dropZone = document.getElementById('drop-zone'); - const processBtn = document.getElementById('process-btn'); - const backBtn = document.getElementById('back-to-tools'); + const fileInput = document.getElementById('file-input') as HTMLInputElement; + const dropZone = document.getElementById('drop-zone'); + const processBtn = document.getElementById('process-btn'); + const backBtn = document.getElementById('back-to-tools'); - if (backBtn) { - backBtn.addEventListener('click', () => { - window.location.href = import.meta.env.BASE_URL; - }); + if (backBtn) { + backBtn.addEventListener('click', () => { + window.location.href = import.meta.env.BASE_URL; + }); + } + + const handleFileSelect = (newFiles: FileList | null) => { + if (!newFiles || newFiles.length === 0) return; + const validFile = Array.from(newFiles).find( + (f) => f.type === 'application/pdf' + ); + + if (!validFile) { + showAlert('Invalid File', 'Please upload a PDF file.'); + return; } - const handleFileSelect = (newFiles: FileList | null) => { - if (!newFiles || newFiles.length === 0) return; - const validFile = Array.from(newFiles).find(f => f.type === 'application/pdf'); + file = validFile; + updateUI(); + }; - if (!validFile) { - showAlert('Invalid File', 'Please upload a PDF file.'); - return; - } + if (fileInput && dropZone) { + fileInput.addEventListener('change', (e) => { + handleFileSelect((e.target as HTMLInputElement).files); + }); - file = validFile; - updateUI(); - }; + dropZone.addEventListener('dragover', (e) => { + e.preventDefault(); + dropZone.classList.add('bg-gray-700'); + }); - if (fileInput && dropZone) { - fileInput.addEventListener('change', (e) => { - handleFileSelect((e.target as HTMLInputElement).files); - }); + dropZone.addEventListener('dragleave', (e) => { + e.preventDefault(); + dropZone.classList.remove('bg-gray-700'); + }); - dropZone.addEventListener('dragover', (e) => { - e.preventDefault(); - dropZone.classList.add('bg-gray-700'); - }); + dropZone.addEventListener('drop', (e) => { + e.preventDefault(); + dropZone.classList.remove('bg-gray-700'); + handleFileSelect(e.dataTransfer?.files ?? null); + }); - dropZone.addEventListener('dragleave', (e) => { - e.preventDefault(); - dropZone.classList.remove('bg-gray-700'); - }); + fileInput.addEventListener('click', () => { + fileInput.value = ''; + }); + } - dropZone.addEventListener('drop', (e) => { - e.preventDefault(); - dropZone.classList.remove('bg-gray-700'); - handleFileSelect(e.dataTransfer?.files ?? null); - }); - - fileInput.addEventListener('click', () => { - fileInput.value = ''; - }); - } - - if (processBtn) { - processBtn.addEventListener('click', convert); - } + if (processBtn) { + processBtn.addEventListener('click', convert); + } }); diff --git a/src/js/logic/pdf-to-docx-page.ts b/src/js/logic/pdf-to-docx-page.ts index 2d9e2b6..3850fbc 100644 --- a/src/js/logic/pdf-to-docx-page.ts +++ b/src/js/logic/pdf-to-docx-page.ts @@ -1,203 +1,216 @@ import { showLoader, hideLoader, showAlert } from '../ui.js'; -import { downloadFile, readFileAsArrayBuffer, formatBytes, getPDFDocument } from '../utils/helpers.js'; +import { + downloadFile, + readFileAsArrayBuffer, + formatBytes, + getPDFDocument, +} from '../utils/helpers.js'; import { state } from '../state.js'; import { createIcons, icons } from 'lucide'; -import { PyMuPDF } from '@bentopdf/pymupdf-wasm'; -import { getWasmBaseUrl } from '../config/wasm-cdn-config.js'; - -const pymupdf = new PyMuPDF(getWasmBaseUrl('pymupdf')); +import { isWasmAvailable, getWasmBaseUrl } from '../config/wasm-cdn-config.js'; +import { showWasmRequiredDialog } from '../utils/wasm-provider.js'; +import { loadPyMuPDF, isPyMuPDFAvailable } from '../utils/pymupdf-loader.js'; document.addEventListener('DOMContentLoaded', () => { - const fileInput = document.getElementById('file-input') as HTMLInputElement; - const dropZone = document.getElementById('drop-zone'); - const processBtn = document.getElementById('process-btn'); - const fileDisplayArea = document.getElementById('file-display-area'); - const convertOptions = document.getElementById('convert-options'); - const fileControls = document.getElementById('file-controls'); - const addMoreBtn = document.getElementById('add-more-btn'); - const clearFilesBtn = document.getElementById('clear-files-btn'); - const backBtn = document.getElementById('back-to-tools'); + const fileInput = document.getElementById('file-input') as HTMLInputElement; + const dropZone = document.getElementById('drop-zone'); + const processBtn = document.getElementById('process-btn'); + const fileDisplayArea = document.getElementById('file-display-area'); + const convertOptions = document.getElementById('convert-options'); + const fileControls = document.getElementById('file-controls'); + const addMoreBtn = document.getElementById('add-more-btn'); + const clearFilesBtn = document.getElementById('clear-files-btn'); + const backBtn = document.getElementById('back-to-tools'); - if (backBtn) { - backBtn.addEventListener('click', () => { - window.location.href = import.meta.env.BASE_URL; - }); - } + if (backBtn) { + backBtn.addEventListener('click', () => { + window.location.href = import.meta.env.BASE_URL; + }); + } - const updateUI = async () => { - if (!fileDisplayArea || !convertOptions || !processBtn || !fileControls) return; + const updateUI = async () => { + if (!fileDisplayArea || !convertOptions || !processBtn || !fileControls) + return; - if (state.files.length > 0) { - fileDisplayArea.innerHTML = ''; + if (state.files.length > 0) { + fileDisplayArea.innerHTML = ''; - for (let index = 0; index < state.files.length; index++) { - const file = state.files[index]; - const fileDiv = document.createElement('div'); - fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm'; + for (let index = 0; index < state.files.length; index++) { + const file = state.files[index]; + const fileDiv = document.createElement('div'); + fileDiv.className = + 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm'; - const infoContainer = document.createElement('div'); - infoContainer.className = 'flex flex-col overflow-hidden'; + const infoContainer = document.createElement('div'); + infoContainer.className = 'flex flex-col overflow-hidden'; - const nameSpan = document.createElement('div'); - nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1'; - nameSpan.textContent = file.name; + const nameSpan = document.createElement('div'); + nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1'; + nameSpan.textContent = file.name; - const metaSpan = document.createElement('div'); - metaSpan.className = 'text-xs text-gray-400'; - metaSpan.textContent = `${formatBytes(file.size)} • Loading pages...`; + const metaSpan = document.createElement('div'); + metaSpan.className = 'text-xs text-gray-400'; + metaSpan.textContent = `${formatBytes(file.size)} • Loading pages...`; - infoContainer.append(nameSpan, metaSpan); + infoContainer.append(nameSpan, metaSpan); - const removeBtn = document.createElement('button'); - removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0'; - removeBtn.innerHTML = ''; - removeBtn.onclick = () => { - state.files = state.files.filter((_: File, i: number) => i !== index); - updateUI(); - }; + const removeBtn = document.createElement('button'); + removeBtn.className = + 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0'; + removeBtn.innerHTML = ''; + removeBtn.onclick = () => { + state.files = state.files.filter((_: File, i: number) => i !== index); + updateUI(); + }; - fileDiv.append(infoContainer, removeBtn); - fileDisplayArea.appendChild(fileDiv); + fileDiv.append(infoContainer, removeBtn); + fileDisplayArea.appendChild(fileDiv); - try { - const arrayBuffer = await readFileAsArrayBuffer(file); - const pdfDoc = await getPDFDocument({ data: arrayBuffer }).promise; - metaSpan.textContent = `${formatBytes(file.size)} • ${pdfDoc.numPages} pages`; - } catch (error) { - metaSpan.textContent = `${formatBytes(file.size)} • Could not load page count`; - } - } - - createIcons({ icons }); - fileControls.classList.remove('hidden'); - convertOptions.classList.remove('hidden'); - (processBtn as HTMLButtonElement).disabled = false; - } else { - fileDisplayArea.innerHTML = ''; - fileControls.classList.add('hidden'); - convertOptions.classList.add('hidden'); - (processBtn as HTMLButtonElement).disabled = true; - } - }; - - const resetState = () => { - state.files = []; - state.pdfDoc = null; - updateUI(); - }; - - const convert = async () => { try { - if (state.files.length === 0) { - showAlert('No Files', 'Please select at least one PDF file.'); - return; - } - - showLoader('Loading PDF converter...'); - await pymupdf.load(); - - if (state.files.length === 1) { - const file = state.files[0]; - showLoader(`Converting ${file.name}...`); - - const docxBlob = await pymupdf.pdfToDocx(file); - const outName = file.name.replace(/\.pdf$/i, '') + '.docx'; - - downloadFile(docxBlob, outName); - hideLoader(); - - showAlert( - 'Conversion Complete', - `Successfully converted ${file.name} to DOCX.`, - 'success', - () => resetState() - ); - } else { - showLoader('Converting multiple PDFs...'); - const JSZip = (await import('jszip')).default; - const zip = new JSZip(); - - for (let i = 0; i < state.files.length; i++) { - const file = state.files[i]; - showLoader(`Converting ${i + 1}/${state.files.length}: ${file.name}...`); - - const docxBlob = await pymupdf.pdfToDocx(file); - const baseName = file.name.replace(/\.pdf$/i, ''); - const arrayBuffer = await docxBlob.arrayBuffer(); - zip.file(`${baseName}.docx`, arrayBuffer); - } - - showLoader('Creating ZIP archive...'); - const zipBlob = await zip.generateAsync({ type: 'blob' }); - - downloadFile(zipBlob, 'converted-documents.zip'); - hideLoader(); - - showAlert( - 'Conversion Complete', - `Successfully converted ${state.files.length} PDF(s) to DOCX.`, - 'success', - () => resetState() - ); - } - } catch (e: any) { - hideLoader(); - showAlert('Error', `An error occurred during conversion. Error: ${e.message}`); + const arrayBuffer = await readFileAsArrayBuffer(file); + const pdfDoc = await getPDFDocument({ data: arrayBuffer }).promise; + metaSpan.textContent = `${formatBytes(file.size)} • ${pdfDoc.numPages} pages`; + } catch (error) { + metaSpan.textContent = `${formatBytes(file.size)} • Could not load page count`; } - }; + } - const handleFileSelect = (files: FileList | null) => { - if (files && files.length > 0) { - const pdfFiles = Array.from(files).filter( - f => f.type === 'application/pdf' || f.name.toLowerCase().endsWith('.pdf') - ); - state.files = [...state.files, ...pdfFiles]; - updateUI(); + createIcons({ icons }); + fileControls.classList.remove('hidden'); + convertOptions.classList.remove('hidden'); + (processBtn as HTMLButtonElement).disabled = false; + } else { + fileDisplayArea.innerHTML = ''; + fileControls.classList.add('hidden'); + convertOptions.classList.add('hidden'); + (processBtn as HTMLButtonElement).disabled = true; + } + }; + + const resetState = () => { + state.files = []; + state.pdfDoc = null; + updateUI(); + }; + + const convert = async () => { + try { + if (state.files.length === 0) { + showAlert('No Files', 'Please select at least one PDF file.'); + return; + } + + showLoader('Loading PDF converter...'); + const pymupdf = await loadPyMuPDF(); + + if (state.files.length === 1) { + const file = state.files[0]; + showLoader(`Converting ${file.name}...`); + + const docxBlob = await pymupdf.pdfToDocx(file); + const outName = file.name.replace(/\.pdf$/i, '') + '.docx'; + + downloadFile(docxBlob, outName); + hideLoader(); + + showAlert( + 'Conversion Complete', + `Successfully converted ${file.name} to DOCX.`, + 'success', + () => resetState() + ); + } else { + showLoader('Converting multiple PDFs...'); + const JSZip = (await import('jszip')).default; + const zip = new JSZip(); + + for (let i = 0; i < state.files.length; i++) { + const file = state.files[i]; + showLoader( + `Converting ${i + 1}/${state.files.length}: ${file.name}...` + ); + + const docxBlob = await pymupdf.pdfToDocx(file); + const baseName = file.name.replace(/\.pdf$/i, ''); + const arrayBuffer = await docxBlob.arrayBuffer(); + zip.file(`${baseName}.docx`, arrayBuffer); } - }; - if (fileInput && dropZone) { - fileInput.addEventListener('change', (e) => { - handleFileSelect((e.target as HTMLInputElement).files); - }); + showLoader('Creating ZIP archive...'); + const zipBlob = await zip.generateAsync({ type: 'blob' }); - dropZone.addEventListener('dragover', (e) => { - e.preventDefault(); - dropZone.classList.add('bg-gray-700'); - }); + downloadFile(zipBlob, 'converted-documents.zip'); + hideLoader(); - dropZone.addEventListener('dragleave', (e) => { - e.preventDefault(); - dropZone.classList.remove('bg-gray-700'); - }); - - dropZone.addEventListener('drop', (e) => { - e.preventDefault(); - dropZone.classList.remove('bg-gray-700'); - const files = e.dataTransfer?.files; - if (files && files.length > 0) { - handleFileSelect(files); - } - }); - - fileInput.addEventListener('click', () => { - fileInput.value = ''; - }); + showAlert( + 'Conversion Complete', + `Successfully converted ${state.files.length} PDF(s) to DOCX.`, + 'success', + () => resetState() + ); + } + } catch (e: any) { + hideLoader(); + showAlert( + 'Error', + `An error occurred during conversion. Error: ${e.message}` + ); } + }; - if (addMoreBtn) { - addMoreBtn.addEventListener('click', () => { - fileInput.click(); - }); + const handleFileSelect = (files: FileList | null) => { + if (files && files.length > 0) { + const pdfFiles = Array.from(files).filter( + (f) => + f.type === 'application/pdf' || f.name.toLowerCase().endsWith('.pdf') + ); + state.files = [...state.files, ...pdfFiles]; + updateUI(); } + }; - if (clearFilesBtn) { - clearFilesBtn.addEventListener('click', () => { - resetState(); - }); - } + if (fileInput && dropZone) { + fileInput.addEventListener('change', (e) => { + handleFileSelect((e.target as HTMLInputElement).files); + }); - if (processBtn) { - processBtn.addEventListener('click', convert); - } + dropZone.addEventListener('dragover', (e) => { + e.preventDefault(); + dropZone.classList.add('bg-gray-700'); + }); + + dropZone.addEventListener('dragleave', (e) => { + e.preventDefault(); + dropZone.classList.remove('bg-gray-700'); + }); + + dropZone.addEventListener('drop', (e) => { + e.preventDefault(); + dropZone.classList.remove('bg-gray-700'); + const files = e.dataTransfer?.files; + if (files && files.length > 0) { + handleFileSelect(files); + } + }); + + fileInput.addEventListener('click', () => { + fileInput.value = ''; + }); + } + + if (addMoreBtn) { + addMoreBtn.addEventListener('click', () => { + fileInput.click(); + }); + } + + if (clearFilesBtn) { + clearFilesBtn.addEventListener('click', () => { + resetState(); + }); + } + + if (processBtn) { + processBtn.addEventListener('click', convert); + } }); diff --git a/src/js/logic/pdf-to-excel-page.ts b/src/js/logic/pdf-to-excel-page.ts index 0d11648..33fa38b 100644 --- a/src/js/logic/pdf-to-excel-page.ts +++ b/src/js/logic/pdf-to-excel-page.ts @@ -1,182 +1,194 @@ import { showLoader, hideLoader, showAlert } from '../ui.js'; import { downloadFile, formatBytes } from '../utils/helpers.js'; import { createIcons, icons } from 'lucide'; -import { PyMuPDF } from '@bentopdf/pymupdf-wasm'; -import { getWasmBaseUrl } from '../config/wasm-cdn-config.js'; +import { isWasmAvailable, getWasmBaseUrl } from '../config/wasm-cdn-config.js'; +import { showWasmRequiredDialog } from '../utils/wasm-provider.js'; +import { loadPyMuPDF, isPyMuPDFAvailable } from '../utils/pymupdf-loader.js'; import * as XLSX from 'xlsx'; - -const pymupdf = new PyMuPDF(getWasmBaseUrl('pymupdf')); let file: File | null = null; const updateUI = () => { - const fileDisplayArea = document.getElementById('file-display-area'); - const optionsPanel = document.getElementById('options-panel'); + const fileDisplayArea = document.getElementById('file-display-area'); + const optionsPanel = document.getElementById('options-panel'); - if (!fileDisplayArea || !optionsPanel) return; + if (!fileDisplayArea || !optionsPanel) return; - fileDisplayArea.innerHTML = ''; + fileDisplayArea.innerHTML = ''; - if (file) { - optionsPanel.classList.remove('hidden'); + if (file) { + optionsPanel.classList.remove('hidden'); - const fileDiv = document.createElement('div'); - fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm'; + const fileDiv = document.createElement('div'); + fileDiv.className = + 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm'; - const infoContainer = document.createElement('div'); - infoContainer.className = 'flex flex-col overflow-hidden'; + const infoContainer = document.createElement('div'); + infoContainer.className = 'flex flex-col overflow-hidden'; - const nameSpan = document.createElement('div'); - nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1'; - nameSpan.textContent = file.name; + const nameSpan = document.createElement('div'); + nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1'; + nameSpan.textContent = file.name; - const metaSpan = document.createElement('div'); - metaSpan.className = 'text-xs text-gray-400'; - metaSpan.textContent = formatBytes(file.size); + const metaSpan = document.createElement('div'); + metaSpan.className = 'text-xs text-gray-400'; + metaSpan.textContent = formatBytes(file.size); - infoContainer.append(nameSpan, metaSpan); + infoContainer.append(nameSpan, metaSpan); - const removeBtn = document.createElement('button'); - removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0'; - removeBtn.innerHTML = ''; - removeBtn.onclick = resetState; + 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); + fileDiv.append(infoContainer, removeBtn); + fileDisplayArea.appendChild(fileDiv); - createIcons({ icons }); - } else { - optionsPanel.classList.add('hidden'); - } + createIcons({ icons }); + } else { + optionsPanel.classList.add('hidden'); + } }; const resetState = () => { - file = null; - const fileInput = document.getElementById('file-input') as HTMLInputElement; - if (fileInput) fileInput.value = ''; - updateUI(); + file = null; + const fileInput = document.getElementById('file-input') as HTMLInputElement; + if (fileInput) fileInput.value = ''; + updateUI(); }; async function convert() { - if (!file) { - showAlert('No File', 'Please upload a PDF file first.'); - return; + if (!file) { + showAlert('No File', 'Please upload a PDF file first.'); + return; + } + + showLoader('Loading Engine...'); + + try { + const pymupdf = await loadPyMuPDF(); + showLoader('Extracting tables...'); + + const doc = await pymupdf.open(file); + const pageCount = doc.pageCount; + const baseName = file.name.replace(/\.[^/.]+$/, ''); + + interface TableData { + page: number; + rows: (string | null)[][]; } - showLoader('Loading Engine...'); + const allTables: TableData[] = []; - try { - await pymupdf.load(); - showLoader('Extracting tables...'); + for (let i = 0; i < pageCount; i++) { + showLoader(`Scanning page ${i + 1} of ${pageCount}...`); + const page = doc.getPage(i); + const tables = page.findTables(); - const doc = await pymupdf.open(file); - const pageCount = doc.pageCount; - const baseName = file.name.replace(/\.[^/.]+$/, ''); - - interface TableData { - page: number; - rows: (string | null)[][]; - } - - const allTables: TableData[] = []; - - for (let i = 0; i < pageCount; i++) { - showLoader(`Scanning page ${i + 1} of ${pageCount}...`); - const page = doc.getPage(i); - const tables = page.findTables(); - - tables.forEach((table) => { - allTables.push({ - page: i + 1, - rows: table.rows - }); - }); - } - - if (allTables.length === 0) { - showAlert('No Tables Found', 'No tables were detected in this PDF.'); - return; - } - - showLoader('Creating Excel file...'); - - const workbook = XLSX.utils.book_new(); - - if (allTables.length === 1) { - const worksheet = XLSX.utils.aoa_to_sheet(allTables[0].rows); - XLSX.utils.book_append_sheet(workbook, worksheet, 'Table'); - } else { - allTables.forEach((table, idx) => { - const sheetName = `Table ${idx + 1} (Page ${table.page})`.substring(0, 31); - const worksheet = XLSX.utils.aoa_to_sheet(table.rows); - XLSX.utils.book_append_sheet(workbook, worksheet, sheetName); - }); - } - - const xlsxData = XLSX.write(workbook, { bookType: 'xlsx', type: 'array' }); - const blob = new Blob([xlsxData], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }); - downloadFile(blob, `${baseName}.xlsx`); - showAlert('Success', `Extracted ${allTables.length} table(s) to Excel!`, 'success', resetState); - } catch (e) { - console.error(e); - const message = e instanceof Error ? e.message : 'Unknown error'; - showAlert('Error', `Failed to convert PDF to Excel. ${message}`); - } finally { - hideLoader(); + tables.forEach((table) => { + allTables.push({ + page: i + 1, + rows: table.rows, + }); + }); } + + if (allTables.length === 0) { + showAlert('No Tables Found', 'No tables were detected in this PDF.'); + return; + } + + showLoader('Creating Excel file...'); + + const workbook = XLSX.utils.book_new(); + + if (allTables.length === 1) { + const worksheet = XLSX.utils.aoa_to_sheet(allTables[0].rows); + XLSX.utils.book_append_sheet(workbook, worksheet, 'Table'); + } else { + allTables.forEach((table, idx) => { + const sheetName = `Table ${idx + 1} (Page ${table.page})`.substring( + 0, + 31 + ); + const worksheet = XLSX.utils.aoa_to_sheet(table.rows); + XLSX.utils.book_append_sheet(workbook, worksheet, sheetName); + }); + } + + const xlsxData = XLSX.write(workbook, { bookType: 'xlsx', type: 'array' }); + const blob = new Blob([xlsxData], { + type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + }); + downloadFile(blob, `${baseName}.xlsx`); + showAlert( + 'Success', + `Extracted ${allTables.length} table(s) to Excel!`, + 'success', + resetState + ); + } catch (e) { + console.error(e); + const message = e instanceof Error ? e.message : 'Unknown error'; + showAlert('Error', `Failed to convert PDF to Excel. ${message}`); + } finally { + hideLoader(); + } } document.addEventListener('DOMContentLoaded', () => { - const fileInput = document.getElementById('file-input') as HTMLInputElement; - const dropZone = document.getElementById('drop-zone'); - const processBtn = document.getElementById('process-btn'); - const backBtn = document.getElementById('back-to-tools'); + const fileInput = document.getElementById('file-input') as HTMLInputElement; + const dropZone = document.getElementById('drop-zone'); + const processBtn = document.getElementById('process-btn'); + const backBtn = document.getElementById('back-to-tools'); - if (backBtn) { - backBtn.addEventListener('click', () => { - window.location.href = import.meta.env.BASE_URL; - }); + if (backBtn) { + backBtn.addEventListener('click', () => { + window.location.href = import.meta.env.BASE_URL; + }); + } + + const handleFileSelect = (newFiles: FileList | null) => { + if (!newFiles || newFiles.length === 0) return; + const validFile = Array.from(newFiles).find( + (f) => f.type === 'application/pdf' + ); + + if (!validFile) { + showAlert('Invalid File', 'Please upload a PDF file.'); + return; } - const handleFileSelect = (newFiles: FileList | null) => { - if (!newFiles || newFiles.length === 0) return; - const validFile = Array.from(newFiles).find(f => f.type === 'application/pdf'); + file = validFile; + updateUI(); + }; - if (!validFile) { - showAlert('Invalid File', 'Please upload a PDF file.'); - return; - } + if (fileInput && dropZone) { + fileInput.addEventListener('change', (e) => { + handleFileSelect((e.target as HTMLInputElement).files); + }); - file = validFile; - updateUI(); - }; + dropZone.addEventListener('dragover', (e) => { + e.preventDefault(); + dropZone.classList.add('bg-gray-700'); + }); - if (fileInput && dropZone) { - fileInput.addEventListener('change', (e) => { - handleFileSelect((e.target as HTMLInputElement).files); - }); + dropZone.addEventListener('dragleave', (e) => { + e.preventDefault(); + dropZone.classList.remove('bg-gray-700'); + }); - dropZone.addEventListener('dragover', (e) => { - e.preventDefault(); - dropZone.classList.add('bg-gray-700'); - }); + dropZone.addEventListener('drop', (e) => { + e.preventDefault(); + dropZone.classList.remove('bg-gray-700'); + handleFileSelect(e.dataTransfer?.files ?? null); + }); - dropZone.addEventListener('dragleave', (e) => { - e.preventDefault(); - dropZone.classList.remove('bg-gray-700'); - }); + fileInput.addEventListener('click', () => { + fileInput.value = ''; + }); + } - dropZone.addEventListener('drop', (e) => { - e.preventDefault(); - dropZone.classList.remove('bg-gray-700'); - handleFileSelect(e.dataTransfer?.files ?? null); - }); - - fileInput.addEventListener('click', () => { - fileInput.value = ''; - }); - } - - if (processBtn) { - processBtn.addEventListener('click', convert); - } + if (processBtn) { + processBtn.addEventListener('click', convert); + } }); diff --git a/src/js/logic/pdf-to-greyscale-page.ts b/src/js/logic/pdf-to-greyscale-page.ts index 9198bfe..64c8df5 100644 --- a/src/js/logic/pdf-to-greyscale-page.ts +++ b/src/js/logic/pdf-to-greyscale-page.ts @@ -1,206 +1,222 @@ import { showLoader, hideLoader, showAlert } from '../ui.js'; -import { downloadFile, formatBytes, readFileAsArrayBuffer, getPDFDocument } from '../utils/helpers.js'; +import { + downloadFile, + formatBytes, + readFileAsArrayBuffer, + getPDFDocument, +} from '../utils/helpers.js'; import { createIcons, icons } from 'lucide'; import { PDFDocument } from 'pdf-lib'; +import { applyGreyscale } from '../utils/image-effects.js'; import * as pdfjsLib from 'pdfjs-dist'; -pdfjsLib.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).toString(); +pdfjsLib.GlobalWorkerOptions.workerSrc = new URL( + 'pdfjs-dist/build/pdf.worker.min.mjs', + import.meta.url +).toString(); let files: File[] = []; const updateUI = () => { - const fileDisplayArea = document.getElementById('file-display-area'); - const optionsPanel = document.getElementById('options-panel'); - const dropZone = document.getElementById('drop-zone'); + const fileDisplayArea = document.getElementById('file-display-area'); + const optionsPanel = document.getElementById('options-panel'); + const dropZone = document.getElementById('drop-zone'); - if (!fileDisplayArea || !optionsPanel || !dropZone) return; + if (!fileDisplayArea || !optionsPanel || !dropZone) return; - fileDisplayArea.innerHTML = ''; + fileDisplayArea.innerHTML = ''; - if (files.length > 0) { - optionsPanel.classList.remove('hidden'); + if (files.length > 0) { + optionsPanel.classList.remove('hidden'); - // Render files synchronously first - files.forEach((file) => { - const fileDiv = document.createElement('div'); - fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm'; + // Render files synchronously first + files.forEach((file) => { + const fileDiv = document.createElement('div'); + fileDiv.className = + 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm'; - const infoContainer = document.createElement('div'); - infoContainer.className = 'flex flex-col overflow-hidden'; + const infoContainer = document.createElement('div'); + infoContainer.className = 'flex flex-col overflow-hidden'; - const nameSpan = document.createElement('div'); - nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1'; - nameSpan.textContent = file.name; + const nameSpan = document.createElement('div'); + nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1'; + nameSpan.textContent = file.name; - const metaSpan = document.createElement('div'); - metaSpan.className = 'text-xs text-gray-400'; - metaSpan.textContent = `${formatBytes(file.size)} • Loading pages...`; // Initial state + const metaSpan = document.createElement('div'); + metaSpan.className = 'text-xs text-gray-400'; + metaSpan.textContent = `${formatBytes(file.size)} • Loading pages...`; // Initial state - infoContainer.append(nameSpan, metaSpan); + infoContainer.append(nameSpan, metaSpan); - const removeBtn = document.createElement('button'); - removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0'; - removeBtn.innerHTML = ''; - removeBtn.onclick = () => { - files = []; - updateUI(); - }; + const removeBtn = document.createElement('button'); + removeBtn.className = + 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0'; + removeBtn.innerHTML = ''; + removeBtn.onclick = () => { + files = []; + updateUI(); + }; - fileDiv.append(infoContainer, removeBtn); - fileDisplayArea.appendChild(fileDiv); + fileDiv.append(infoContainer, removeBtn); + fileDisplayArea.appendChild(fileDiv); - // Fetch page count asynchronously - readFileAsArrayBuffer(file).then(buffer => { - return getPDFDocument(buffer).promise; - }).then(pdf => { - metaSpan.textContent = `${formatBytes(file.size)} • ${pdf.numPages} page${pdf.numPages !== 1 ? 's' : ''}`; - }).catch(e => { - console.warn('Error loading PDF page count:', e); - metaSpan.textContent = formatBytes(file.size); - }); + // Fetch page count asynchronously + readFileAsArrayBuffer(file) + .then((buffer) => { + return getPDFDocument(buffer).promise; + }) + .then((pdf) => { + metaSpan.textContent = `${formatBytes(file.size)} • ${pdf.numPages} page${pdf.numPages !== 1 ? 's' : ''}`; + }) + .catch((e) => { + console.warn('Error loading PDF page count:', e); + metaSpan.textContent = formatBytes(file.size); }); + }); - // Initialize icons immediately after synchronous render - createIcons({ icons }); - } else { - optionsPanel.classList.add('hidden'); - } + // Initialize icons immediately after synchronous render + createIcons({ icons }); + } else { + optionsPanel.classList.add('hidden'); + } }; const resetState = () => { - files = []; - const fileInput = document.getElementById('file-input') as HTMLInputElement; - if (fileInput) fileInput.value = ''; - updateUI(); + files = []; + const fileInput = document.getElementById('file-input') as HTMLInputElement; + if (fileInput) fileInput.value = ''; + updateUI(); }; async function convert() { - if (files.length === 0) { - showAlert('No File', 'Please upload a PDF file first.'); - return; - } - showLoader('Converting to greyscale...'); - try { - const pdfBytes = await readFileAsArrayBuffer(files[0]) as ArrayBuffer; - const pdfDoc = await PDFDocument.load(pdfBytes); - const pages = pdfDoc.getPages(); + if (files.length === 0) { + showAlert('No File', 'Please upload a PDF file first.'); + return; + } + showLoader('Converting to greyscale...'); + try { + const pdfBytes = (await readFileAsArrayBuffer(files[0])) as ArrayBuffer; + const pdfDoc = await PDFDocument.load(pdfBytes); + const pages = pdfDoc.getPages(); - const pdfjsDoc = await getPDFDocument({ data: pdfBytes }).promise; - const newPdfDoc = await PDFDocument.create(); + const pdfjsDoc = await getPDFDocument({ data: pdfBytes }).promise; + const newPdfDoc = await PDFDocument.create(); - for (let i = 1; i <= pdfjsDoc.numPages; i++) { - const page = await pdfjsDoc.getPage(i); - const viewport = page.getViewport({ scale: 2.0 }); - const canvas = document.createElement('canvas'); - const context = canvas.getContext('2d'); - canvas.height = viewport.height; - canvas.width = viewport.width; + for (let i = 1; i <= pdfjsDoc.numPages; i++) { + const page = await pdfjsDoc.getPage(i); + const viewport = page.getViewport({ scale: 2.0 }); + const canvas = document.createElement('canvas'); + const context = canvas.getContext('2d'); + canvas.height = viewport.height; + canvas.width = viewport.width; - await page.render({ canvasContext: context!, viewport: viewport, canvas }).promise; + await page.render({ canvasContext: context!, viewport: viewport, canvas }) + .promise; - const imageData = context!.getImageData(0, 0, canvas.width, canvas.height); - const data = imageData.data; + const imageData = context!.getImageData( + 0, + 0, + canvas.width, + canvas.height + ); + applyGreyscale(imageData); + context!.putImageData(imageData, 0, 0); - // Convert to greyscale - for (let j = 0; j < data.length; j += 4) { - const grey = Math.round(0.299 * data[j] + 0.587 * data[j + 1] + 0.114 * data[j + 2]); - data[j] = grey; - data[j + 1] = grey; - data[j + 2] = grey; - } + const jpegBlob = await new Promise((resolve) => + canvas.toBlob(resolve, 'image/jpeg', 0.9) + ); - context!.putImageData(imageData, 0, 0); - - const jpegBlob = await new Promise((resolve) => - canvas.toBlob(resolve, 'image/jpeg', 0.9) - ); - - if (jpegBlob) { - const jpegBytes = await jpegBlob.arrayBuffer(); - const jpegImage = await newPdfDoc.embedJpg(jpegBytes); - const newPage = newPdfDoc.addPage([viewport.width, viewport.height]); - newPage.drawImage(jpegImage, { - x: 0, - y: 0, - width: viewport.width, - height: viewport.height, - }); - } - } - - const resultBytes = await newPdfDoc.save(); - downloadFile( - new Blob([new Uint8Array(resultBytes)], { type: 'application/pdf' }), - 'greyscale.pdf' - ); - showAlert('Success', 'PDF converted to greyscale successfully!', 'success', () => { - resetState(); + if (jpegBlob) { + const jpegBytes = await jpegBlob.arrayBuffer(); + const jpegImage = await newPdfDoc.embedJpg(jpegBytes); + const newPage = newPdfDoc.addPage([viewport.width, viewport.height]); + newPage.drawImage(jpegImage, { + x: 0, + y: 0, + width: viewport.width, + height: viewport.height, }); - } catch (e) { - console.error(e); - showAlert( - 'Error', - 'Failed to convert PDF to greyscale. The file might be corrupted.' - ); - } finally { - hideLoader(); + } } + + const resultBytes = await newPdfDoc.save(); + downloadFile( + new Blob([new Uint8Array(resultBytes)], { type: 'application/pdf' }), + 'greyscale.pdf' + ); + showAlert( + 'Success', + 'PDF converted to greyscale successfully!', + 'success', + () => { + resetState(); + } + ); + } catch (e) { + console.error(e); + showAlert( + 'Error', + 'Failed to convert PDF to greyscale. The file might be corrupted.' + ); + } finally { + hideLoader(); + } } document.addEventListener('DOMContentLoaded', () => { - const fileInput = document.getElementById('file-input') as HTMLInputElement; - const dropZone = document.getElementById('drop-zone'); - const processBtn = document.getElementById('process-btn'); - const backBtn = document.getElementById('back-to-tools'); + const fileInput = document.getElementById('file-input') as HTMLInputElement; + const dropZone = document.getElementById('drop-zone'); + const processBtn = document.getElementById('process-btn'); + const backBtn = document.getElementById('back-to-tools'); - if (backBtn) { - backBtn.addEventListener('click', () => { - window.location.href = import.meta.env.BASE_URL; - }); + if (backBtn) { + backBtn.addEventListener('click', () => { + window.location.href = import.meta.env.BASE_URL; + }); + } + + const handleFileSelect = (newFiles: FileList | null) => { + if (!newFiles || newFiles.length === 0) return; + const validFiles = Array.from(newFiles).filter( + (file) => file.type === 'application/pdf' + ); + + if (validFiles.length === 0) { + showAlert('Invalid File', 'Please upload a PDF file.'); + return; } - const handleFileSelect = (newFiles: FileList | null) => { - if (!newFiles || newFiles.length === 0) return; - const validFiles = Array.from(newFiles).filter( - (file) => file.type === 'application/pdf' - ); + files = [validFiles[0]]; + updateUI(); + }; - if (validFiles.length === 0) { - showAlert('Invalid File', 'Please upload a PDF file.'); - return; - } + if (fileInput && dropZone) { + fileInput.addEventListener('change', (e) => { + handleFileSelect((e.target as HTMLInputElement).files); + }); - files = [validFiles[0]]; - updateUI(); - }; + dropZone.addEventListener('dragover', (e) => { + e.preventDefault(); + dropZone.classList.add('bg-gray-700'); + }); - if (fileInput && dropZone) { - fileInput.addEventListener('change', (e) => { - handleFileSelect((e.target as HTMLInputElement).files); - }); + dropZone.addEventListener('dragleave', (e) => { + e.preventDefault(); + dropZone.classList.remove('bg-gray-700'); + }); - dropZone.addEventListener('dragover', (e) => { - e.preventDefault(); - dropZone.classList.add('bg-gray-700'); - }); + dropZone.addEventListener('drop', (e) => { + e.preventDefault(); + dropZone.classList.remove('bg-gray-700'); + handleFileSelect(e.dataTransfer?.files ?? null); + }); - dropZone.addEventListener('dragleave', (e) => { - e.preventDefault(); - dropZone.classList.remove('bg-gray-700'); - }); + fileInput.addEventListener('click', () => { + fileInput.value = ''; + }); + } - dropZone.addEventListener('drop', (e) => { - e.preventDefault(); - dropZone.classList.remove('bg-gray-700'); - handleFileSelect(e.dataTransfer?.files ?? null); - }); - - fileInput.addEventListener('click', () => { - fileInput.value = ''; - }); - } - - if (processBtn) { - processBtn.addEventListener('click', convert); - } + if (processBtn) { + processBtn.addEventListener('click', convert); + } }); diff --git a/src/js/logic/pdf-to-jpg-page.ts b/src/js/logic/pdf-to-jpg-page.ts index b01bdc3..dc43566 100644 --- a/src/js/logic/pdf-to-jpg-page.ts +++ b/src/js/logic/pdf-to-jpg-page.ts @@ -1,193 +1,237 @@ import { showLoader, hideLoader, showAlert } from '../ui.js'; -import { downloadFile, formatBytes, readFileAsArrayBuffer, getPDFDocument } from '../utils/helpers.js'; +import { + downloadFile, + formatBytes, + readFileAsArrayBuffer, + getPDFDocument, + getCleanPdfFilename, +} from '../utils/helpers.js'; import { createIcons, icons } from 'lucide'; import JSZip from 'jszip'; import * as pdfjsLib from 'pdfjs-dist'; +import { PDFPageProxy } from 'pdfjs-dist'; -pdfjsLib.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).toString(); +pdfjsLib.GlobalWorkerOptions.workerSrc = new URL( + 'pdfjs-dist/build/pdf.worker.min.mjs', + import.meta.url +).toString(); let files: File[] = []; const updateUI = () => { - const fileDisplayArea = document.getElementById('file-display-area'); - const optionsPanel = document.getElementById('options-panel'); - const dropZone = document.getElementById('drop-zone'); + const fileDisplayArea = document.getElementById('file-display-area'); + const optionsPanel = document.getElementById('options-panel'); + const dropZone = document.getElementById('drop-zone'); - if (!fileDisplayArea || !optionsPanel || !dropZone) return; + if (!fileDisplayArea || !optionsPanel || !dropZone) return; - fileDisplayArea.innerHTML = ''; + fileDisplayArea.innerHTML = ''; - if (files.length > 0) { - optionsPanel.classList.remove('hidden'); + if (files.length > 0) { + optionsPanel.classList.remove('hidden'); - files.forEach((file) => { - const fileDiv = document.createElement('div'); - fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm'; + files.forEach((file) => { + const fileDiv = document.createElement('div'); + fileDiv.className = + 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm'; - const infoContainer = document.createElement('div'); - infoContainer.className = 'flex flex-col overflow-hidden'; + const infoContainer = document.createElement('div'); + infoContainer.className = 'flex flex-col overflow-hidden'; - const nameSpan = document.createElement('div'); - nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1'; - nameSpan.textContent = file.name; + const nameSpan = document.createElement('div'); + nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1'; + nameSpan.textContent = file.name; - const metaSpan = document.createElement('div'); - metaSpan.className = 'text-xs text-gray-400'; - metaSpan.textContent = `${formatBytes(file.size)} • Loading pages...`; // Initial state + const metaSpan = document.createElement('div'); + metaSpan.className = 'text-xs text-gray-400'; + metaSpan.textContent = `${formatBytes(file.size)} • Loading pages...`; // Initial state - infoContainer.append(nameSpan, metaSpan); + infoContainer.append(nameSpan, metaSpan); - const removeBtn = document.createElement('button'); - removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0'; - removeBtn.innerHTML = ''; - removeBtn.onclick = () => { - files = []; - updateUI(); - }; + const removeBtn = document.createElement('button'); + removeBtn.className = + 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0'; + removeBtn.innerHTML = ''; + removeBtn.onclick = () => { + files = []; + updateUI(); + }; - fileDiv.append(infoContainer, removeBtn); - fileDisplayArea.appendChild(fileDiv); + fileDiv.append(infoContainer, removeBtn); + fileDisplayArea.appendChild(fileDiv); - // Fetch page count asynchronously - readFileAsArrayBuffer(file).then(buffer => { - return getPDFDocument(buffer).promise; - }).then(pdf => { - metaSpan.textContent = `${formatBytes(file.size)} • ${pdf.numPages} page${pdf.numPages !== 1 ? 's' : ''}`; - }).catch(e => { - console.warn('Error loading PDF page count:', e); - metaSpan.textContent = formatBytes(file.size); - }); + // Fetch page count asynchronously + readFileAsArrayBuffer(file) + .then((buffer) => { + return getPDFDocument(buffer).promise; + }) + .then((pdf) => { + metaSpan.textContent = `${formatBytes(file.size)} • ${pdf.numPages} page${pdf.numPages !== 1 ? 's' : ''}`; + }) + .catch((e) => { + console.warn('Error loading PDF page count:', e); + metaSpan.textContent = formatBytes(file.size); }); + }); - // Initialize icons immediately after synchronous render - createIcons({ icons }); - } else { - optionsPanel.classList.add('hidden'); - } + // Initialize icons immediately after synchronous render + createIcons({ icons }); + } else { + optionsPanel.classList.add('hidden'); + } }; const resetState = () => { - files = []; - const fileInput = document.getElementById('file-input') as HTMLInputElement; - if (fileInput) fileInput.value = ''; - const qualitySlider = document.getElementById('jpg-quality') as HTMLInputElement; - const qualityValue = document.getElementById('jpg-quality-value'); - if (qualitySlider) qualitySlider.value = '0.9'; - if (qualityValue) qualityValue.textContent = '90%'; - updateUI(); + files = []; + const fileInput = document.getElementById('file-input') as HTMLInputElement; + if (fileInput) fileInput.value = ''; + const qualitySlider = document.getElementById( + 'jpg-quality' + ) as HTMLInputElement; + const qualityValue = document.getElementById('jpg-quality-value'); + if (qualitySlider) qualitySlider.value = '0.9'; + if (qualityValue) qualityValue.textContent = '90%'; + updateUI(); }; async function convert() { - if (files.length === 0) { - showAlert('No File', 'Please upload a PDF file first.'); - return; - } - showLoader('Converting to JPG...'); - try { - const pdf = await getPDFDocument( - await readFileAsArrayBuffer(files[0]) - ).promise; - const zip = new JSZip(); + if (files.length === 0) { + showAlert('No File', 'Please upload a PDF file first.'); + return; + } + showLoader('Converting to JPG...'); + try { + const pdf = await getPDFDocument(await readFileAsArrayBuffer(files[0])) + .promise; - const qualityInput = document.getElementById('jpg-quality') as HTMLInputElement; - const quality = qualityInput ? parseFloat(qualityInput.value) : 0.9; + const qualityInput = document.getElementById( + 'jpg-quality' + ) as HTMLInputElement; + const quality = qualityInput ? parseFloat(qualityInput.value) : 0.9; - for (let i = 1; i <= pdf.numPages; i++) { - const page = await pdf.getPage(i); - const viewport = page.getViewport({ scale: 2.0 }); - const canvas = document.createElement('canvas'); - const context = canvas.getContext('2d'); - canvas.height = viewport.height; - canvas.width = viewport.width; - - await page.render({ canvasContext: context!, viewport: viewport, canvas }).promise; - - const blob = await new Promise((resolve) => - canvas.toBlob(resolve, 'image/jpeg', quality) - ); - if (blob) { - zip.file(`page_${i}.jpg`, blob); - } + if (pdf.numPages === 1) { + const page = await pdf.getPage(1); + const blob = await renderPage(page, quality); + downloadFile(blob, getCleanPdfFilename(files[0].name) + '.jpg'); + } else { + const zip = new JSZip(); + for (let i = 1; i <= pdf.numPages; i++) { + const page = await pdf.getPage(i); + const blob = await renderPage(page, quality); + if (blob) { + zip.file(`page_${i}.jpg`, blob); } + } - const zipBlob = await zip.generateAsync({ type: 'blob' }); - downloadFile(zipBlob, 'converted_images.zip'); - showAlert('Success', 'PDF converted to JPGs successfully!', 'success', () => { - resetState(); - }); - } catch (e) { - console.error(e); - showAlert( - 'Error', - 'Failed to convert PDF to JPG. The file might be corrupted.' - ); - } finally { - hideLoader(); + const zipBlob = await zip.generateAsync({ type: 'blob' }); + downloadFile(zipBlob, getCleanPdfFilename(files[0].name) + '_jpgs.zip'); } + + showAlert( + 'Success', + 'PDF converted to JPGs successfully!', + 'success', + () => { + resetState(); + } + ); + } catch (e) { + console.error(e); + showAlert( + 'Error', + 'Failed to convert PDF to JPG. The file might be corrupted.' + ); + } finally { + hideLoader(); + } +} + +async function renderPage( + page: PDFPageProxy, + quality: number +): Promise { + const viewport = page.getViewport({ scale: 2.0 }); + const canvas = document.createElement('canvas'); + const context = canvas.getContext('2d'); + canvas.height = viewport.height; + canvas.width = viewport.width; + + await page.render({ + canvasContext: context!, + viewport: viewport, + canvas, + }).promise; + + const blob = await new Promise((resolve) => + canvas.toBlob(resolve, 'image/jpeg', quality) + ); + return blob; } document.addEventListener('DOMContentLoaded', () => { - const fileInput = document.getElementById('file-input') as HTMLInputElement; - const dropZone = document.getElementById('drop-zone'); - const processBtn = document.getElementById('process-btn'); - const backBtn = document.getElementById('back-to-tools'); - const qualitySlider = document.getElementById('jpg-quality') as HTMLInputElement; - const qualityValue = document.getElementById('jpg-quality-value'); + const fileInput = document.getElementById('file-input') as HTMLInputElement; + const dropZone = document.getElementById('drop-zone'); + const processBtn = document.getElementById('process-btn'); + const backBtn = document.getElementById('back-to-tools'); + const qualitySlider = document.getElementById( + 'jpg-quality' + ) as HTMLInputElement; + const qualityValue = document.getElementById('jpg-quality-value'); - if (backBtn) { - backBtn.addEventListener('click', () => { - window.location.href = import.meta.env.BASE_URL; - }); + if (backBtn) { + backBtn.addEventListener('click', () => { + window.location.href = import.meta.env.BASE_URL; + }); + } + + if (qualitySlider && qualityValue) { + qualitySlider.addEventListener('input', () => { + qualityValue.textContent = `${Math.round(parseFloat(qualitySlider.value) * 100)}%`; + }); + } + + const handleFileSelect = (newFiles: FileList | null) => { + if (!newFiles || newFiles.length === 0) return; + const validFiles = Array.from(newFiles).filter( + (file) => file.type === 'application/pdf' + ); + + if (validFiles.length === 0) { + showAlert('Invalid File', 'Please upload a PDF file.'); + return; } - if (qualitySlider && qualityValue) { - qualitySlider.addEventListener('input', () => { - qualityValue.textContent = `${Math.round(parseFloat(qualitySlider.value) * 100)}%`; - }); - } + files = [validFiles[0]]; + updateUI(); + }; - const handleFileSelect = (newFiles: FileList | null) => { - if (!newFiles || newFiles.length === 0) return; - const validFiles = Array.from(newFiles).filter( - (file) => file.type === 'application/pdf' - ); + if (fileInput && dropZone) { + fileInput.addEventListener('change', (e) => { + handleFileSelect((e.target as HTMLInputElement).files); + }); - if (validFiles.length === 0) { - showAlert('Invalid File', 'Please upload a PDF file.'); - return; - } + dropZone.addEventListener('dragover', (e) => { + e.preventDefault(); + dropZone.classList.add('bg-gray-700'); + }); - files = [validFiles[0]]; - updateUI(); - }; + dropZone.addEventListener('dragleave', (e) => { + e.preventDefault(); + dropZone.classList.remove('bg-gray-700'); + }); - if (fileInput && dropZone) { - fileInput.addEventListener('change', (e) => { - handleFileSelect((e.target as HTMLInputElement).files); - }); + dropZone.addEventListener('drop', (e) => { + e.preventDefault(); + dropZone.classList.remove('bg-gray-700'); + handleFileSelect(e.dataTransfer?.files ?? null); + }); - dropZone.addEventListener('dragover', (e) => { - e.preventDefault(); - dropZone.classList.add('bg-gray-700'); - }); + fileInput.addEventListener('click', () => { + fileInput.value = ''; + }); + } - dropZone.addEventListener('dragleave', (e) => { - e.preventDefault(); - dropZone.classList.remove('bg-gray-700'); - }); - - dropZone.addEventListener('drop', (e) => { - e.preventDefault(); - dropZone.classList.remove('bg-gray-700'); - handleFileSelect(e.dataTransfer?.files ?? null); - }); - - fileInput.addEventListener('click', () => { - fileInput.value = ''; - }); - } - - if (processBtn) { - processBtn.addEventListener('click', convert); - } + if (processBtn) { + processBtn.addEventListener('click', convert); + } }); diff --git a/src/js/logic/pdf-to-json.ts b/src/js/logic/pdf-to-json.ts index babeaeb..3ada998 100644 --- a/src/js/logic/pdf-to-json.ts +++ b/src/js/logic/pdf-to-json.ts @@ -1,101 +1,133 @@ -import JSZip from 'jszip' -import { downloadFile, formatBytes, readFileAsArrayBuffer } from '../utils/helpers'; +import JSZip from 'jszip'; +import { + downloadFile, + formatBytes, + readFileAsArrayBuffer, +} from '../utils/helpers'; import { initializeGlobalShortcuts } from '../utils/shortcuts-init.js'; +import { isCpdfAvailable } from '../utils/cpdf-helper.js'; +import { + showWasmRequiredDialog, + WasmProvider, +} from '../utils/wasm-provider.js'; -const worker = new Worker(import.meta.env.BASE_URL + 'workers/pdf-to-json.worker.js'); +const worker = new Worker( + import.meta.env.BASE_URL + 'workers/pdf-to-json.worker.js' +); -let selectedFiles: File[] = [] +let selectedFiles: File[] = []; -const pdfFilesInput = document.getElementById('pdfFiles') as HTMLInputElement -const convertBtn = document.getElementById('convertBtn') as HTMLButtonElement -const statusMessage = document.getElementById('status-message') as HTMLDivElement -const fileListDiv = document.getElementById('fileList') as HTMLDivElement -const backToToolsBtn = document.getElementById('back-to-tools') as HTMLButtonElement +const pdfFilesInput = document.getElementById('pdfFiles') as HTMLInputElement; +const convertBtn = document.getElementById('convertBtn') as HTMLButtonElement; +const statusMessage = document.getElementById( + 'status-message' +) as HTMLDivElement; +const fileListDiv = document.getElementById('fileList') as HTMLDivElement; +const backToToolsBtn = document.getElementById( + 'back-to-tools' +) as HTMLButtonElement; function showStatus( message: string, type: 'success' | 'error' | 'info' = 'info' ) { - statusMessage.textContent = message - statusMessage.className = `mt-4 p-3 rounded-lg text-sm ${type === 'success' - ? 'bg-green-900 text-green-200' - : type === 'error' - ? 'bg-red-900 text-red-200' - : 'bg-blue-900 text-blue-200' - }` - statusMessage.classList.remove('hidden') + statusMessage.textContent = message; + statusMessage.className = `mt-4 p-3 rounded-lg text-sm ${ + type === 'success' + ? 'bg-green-900 text-green-200' + : type === 'error' + ? 'bg-red-900 text-red-200' + : 'bg-blue-900 text-blue-200' + }`; + statusMessage.classList.remove('hidden'); } function hideStatus() { - statusMessage.classList.add('hidden') + statusMessage.classList.add('hidden'); } function updateFileList() { - fileListDiv.innerHTML = '' + fileListDiv.innerHTML = ''; if (selectedFiles.length === 0) { - fileListDiv.classList.add('hidden') - return + fileListDiv.classList.add('hidden'); + return; } - fileListDiv.classList.remove('hidden') + fileListDiv.classList.remove('hidden'); selectedFiles.forEach((file) => { - const fileDiv = document.createElement('div') - fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm mb-2' + const fileDiv = document.createElement('div'); + fileDiv.className = + 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm mb-2'; - const nameSpan = document.createElement('span') - nameSpan.className = 'truncate font-medium text-gray-200' - nameSpan.textContent = file.name + const nameSpan = document.createElement('span'); + nameSpan.className = 'truncate font-medium text-gray-200'; + nameSpan.textContent = file.name; - const sizeSpan = document.createElement('span') - sizeSpan.className = 'flex-shrink-0 ml-4 text-gray-400' - sizeSpan.textContent = formatBytes(file.size) + const sizeSpan = document.createElement('span'); + sizeSpan.className = 'flex-shrink-0 ml-4 text-gray-400'; + sizeSpan.textContent = formatBytes(file.size); - fileDiv.append(nameSpan, sizeSpan) - fileListDiv.appendChild(fileDiv) - }) + fileDiv.append(nameSpan, sizeSpan); + fileListDiv.appendChild(fileDiv); + }); } pdfFilesInput.addEventListener('change', (e) => { - const target = e.target as HTMLInputElement + const target = e.target as HTMLInputElement; if (target.files && target.files.length > 0) { - selectedFiles = Array.from(target.files) - convertBtn.disabled = selectedFiles.length === 0 - updateFileList() + selectedFiles = Array.from(target.files); + convertBtn.disabled = selectedFiles.length === 0; + updateFileList(); if (selectedFiles.length === 0) { - showStatus('Please select at least 1 PDF file', 'info') + showStatus('Please select at least 1 PDF file', 'info'); } else { - showStatus(`${selectedFiles.length} file(s) selected. Ready to convert!`, 'info') + showStatus( + `${selectedFiles.length} file(s) selected. Ready to convert!`, + 'info' + ); } } -}) +}); async function convertPDFsToJSON() { if (selectedFiles.length === 0) { - showStatus('Please select at least 1 PDF file', 'error') - return + showStatus('Please select at least 1 PDF file', 'error'); + return; + } + + // Check if CPDF is configured + if (!isCpdfAvailable()) { + showWasmRequiredDialog('cpdf'); + return; } try { - convertBtn.disabled = true - showStatus('Reading files (Main Thread)...', 'info') + convertBtn.disabled = true; + showStatus('Reading files (Main Thread)...', 'info'); const fileBuffers = await Promise.all( - selectedFiles.map(file => readFileAsArrayBuffer(file)) - ) + selectedFiles.map((file) => readFileAsArrayBuffer(file)) + ); - showStatus('Converting PDFs to JSON..', 'info') - - worker.postMessage({ - command: 'convert', - fileBuffers: fileBuffers, - fileNames: selectedFiles.map(f => f.name) - }, fileBuffers); + showStatus('Converting PDFs to JSON..', 'info'); + worker.postMessage( + { + command: 'convert', + fileBuffers: fileBuffers, + fileNames: selectedFiles.map((f) => f.name), + cpdfUrl: WasmProvider.getUrl('cpdf')! + 'coherentpdf.browser.min.js', + }, + fileBuffers + ); } catch (error) { - console.error('Error reading files:', error) - showStatus(`❌ Error reading files: ${error instanceof Error ? error.message : 'Unknown error'}`, 'error') - convertBtn.disabled = false + console.error('Error reading files:', error); + showStatus( + `❌ Error reading files: ${error instanceof Error ? error.message : 'Unknown error'}`, + 'error' + ); + convertBtn.disabled = false; } } @@ -103,38 +135,45 @@ worker.onmessage = async (e: MessageEvent) => { convertBtn.disabled = false; if (e.data.status === 'success') { - const jsonFiles = e.data.jsonFiles as Array<{ name: string, data: ArrayBuffer }>; + const jsonFiles = e.data.jsonFiles as Array<{ + name: string; + data: ArrayBuffer; + }>; try { - showStatus('Creating ZIP file...', 'info') + showStatus('Creating ZIP file...', 'info'); - const zip = new JSZip() + const zip = new JSZip(); jsonFiles.forEach(({ name, data }) => { - const jsonName = name.replace(/\.pdf$/i, '.json') - const uint8Array = new Uint8Array(data) - zip.file(jsonName, uint8Array) - }) + const jsonName = name.replace(/\.pdf$/i, '.json'); + const uint8Array = new Uint8Array(data); + zip.file(jsonName, uint8Array); + }); - const zipBlob = await zip.generateAsync({ type: 'blob' }) - downloadFile(zipBlob, 'pdfs-to-json.zip') + const zipBlob = await zip.generateAsync({ type: 'blob' }); + downloadFile(zipBlob, 'pdfs-to-json.zip'); - showStatus('✅ PDFs converted to JSON successfully! ZIP download started.', 'success') + showStatus( + '✅ PDFs converted to JSON successfully! ZIP download started.', + 'success' + ); - selectedFiles = [] - pdfFilesInput.value = '' - fileListDiv.innerHTML = '' - fileListDiv.classList.add('hidden') - convertBtn.disabled = true + selectedFiles = []; + pdfFilesInput.value = ''; + fileListDiv.innerHTML = ''; + fileListDiv.classList.add('hidden'); + convertBtn.disabled = true; setTimeout(() => { - hideStatus() - }, 3000) - + hideStatus(); + }, 3000); } catch (error) { - console.error('Error creating ZIP:', error) - showStatus(`❌ Error creating ZIP: ${error instanceof Error ? error.message : 'Unknown error'}`, 'error') + console.error('Error creating ZIP:', error); + showStatus( + `❌ Error creating ZIP: ${error instanceof Error ? error.message : 'Unknown error'}`, + 'error' + ); } - } else if (e.data.status === 'error') { const errorMessage = e.data.message || 'Unknown error occurred in worker.'; console.error('Worker Error:', errorMessage); @@ -144,11 +183,11 @@ worker.onmessage = async (e: MessageEvent) => { if (backToToolsBtn) { backToToolsBtn.addEventListener('click', () => { - window.location.href = import.meta.env.BASE_URL - }) + window.location.href = import.meta.env.BASE_URL; + }); } -convertBtn.addEventListener('click', convertPDFsToJSON) +convertBtn.addEventListener('click', convertPDFsToJSON); -showStatus('Select PDF files to get started', 'info') -initializeGlobalShortcuts() +showStatus('Select PDF files to get started', 'info'); +initializeGlobalShortcuts(); diff --git a/src/js/logic/pdf-to-markdown-page.ts b/src/js/logic/pdf-to-markdown-page.ts index d35dc86..f99294b 100644 --- a/src/js/logic/pdf-to-markdown-page.ts +++ b/src/js/logic/pdf-to-markdown-page.ts @@ -1,206 +1,221 @@ import { showLoader, hideLoader, showAlert } from '../ui.js'; -import { downloadFile, readFileAsArrayBuffer, formatBytes, getPDFDocument } from '../utils/helpers.js'; +import { + downloadFile, + readFileAsArrayBuffer, + formatBytes, + getPDFDocument, +} from '../utils/helpers.js'; import { state } from '../state.js'; import { createIcons, icons } from 'lucide'; -import { PyMuPDF } from '@bentopdf/pymupdf-wasm'; -import { getWasmBaseUrl } from '../config/wasm-cdn-config.js'; - -const pymupdf = new PyMuPDF(getWasmBaseUrl('pymupdf')); +import { isWasmAvailable, getWasmBaseUrl } from '../config/wasm-cdn-config.js'; +import { showWasmRequiredDialog } from '../utils/wasm-provider.js'; +import { loadPyMuPDF, isPyMuPDFAvailable } from '../utils/pymupdf-loader.js'; document.addEventListener('DOMContentLoaded', () => { - const fileInput = document.getElementById('file-input') as HTMLInputElement; - const dropZone = document.getElementById('drop-zone'); - const processBtn = document.getElementById('process-btn'); - const fileDisplayArea = document.getElementById('file-display-area'); - const convertOptions = document.getElementById('convert-options'); - const fileControls = document.getElementById('file-controls'); - const addMoreBtn = document.getElementById('add-more-btn'); - const clearFilesBtn = document.getElementById('clear-files-btn'); - const backBtn = document.getElementById('back-to-tools'); - const includeImagesCheckbox = document.getElementById('include-images') as HTMLInputElement; + const fileInput = document.getElementById('file-input') as HTMLInputElement; + const dropZone = document.getElementById('drop-zone'); + const processBtn = document.getElementById('process-btn'); + const fileDisplayArea = document.getElementById('file-display-area'); + const convertOptions = document.getElementById('convert-options'); + const fileControls = document.getElementById('file-controls'); + const addMoreBtn = document.getElementById('add-more-btn'); + const clearFilesBtn = document.getElementById('clear-files-btn'); + const backBtn = document.getElementById('back-to-tools'); + const includeImagesCheckbox = document.getElementById( + 'include-images' + ) as HTMLInputElement; - if (backBtn) { - backBtn.addEventListener('click', () => { - window.location.href = import.meta.env.BASE_URL; - }); - } + if (backBtn) { + backBtn.addEventListener('click', () => { + window.location.href = import.meta.env.BASE_URL; + }); + } - const updateUI = async () => { - if (!fileDisplayArea || !convertOptions || !processBtn || !fileControls) return; + const updateUI = async () => { + if (!fileDisplayArea || !convertOptions || !processBtn || !fileControls) + return; - if (state.files.length > 0) { - fileDisplayArea.innerHTML = ''; + if (state.files.length > 0) { + fileDisplayArea.innerHTML = ''; - for (let index = 0; index < state.files.length; index++) { - const file = state.files[index]; - const fileDiv = document.createElement('div'); - fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm'; + for (let index = 0; index < state.files.length; index++) { + const file = state.files[index]; + const fileDiv = document.createElement('div'); + fileDiv.className = + 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm'; - const infoContainer = document.createElement('div'); - infoContainer.className = 'flex flex-col overflow-hidden'; + const infoContainer = document.createElement('div'); + infoContainer.className = 'flex flex-col overflow-hidden'; - const nameSpan = document.createElement('div'); - nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1'; - nameSpan.textContent = file.name; + const nameSpan = document.createElement('div'); + nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1'; + nameSpan.textContent = file.name; - const metaSpan = document.createElement('div'); - metaSpan.className = 'text-xs text-gray-400'; - metaSpan.textContent = `${formatBytes(file.size)} • Loading pages...`; + const metaSpan = document.createElement('div'); + metaSpan.className = 'text-xs text-gray-400'; + metaSpan.textContent = `${formatBytes(file.size)} • Loading pages...`; - infoContainer.append(nameSpan, metaSpan); + infoContainer.append(nameSpan, metaSpan); - const removeBtn = document.createElement('button'); - removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0'; - removeBtn.innerHTML = ''; - removeBtn.onclick = () => { - state.files = state.files.filter((_: File, i: number) => i !== index); - updateUI(); - }; + const removeBtn = document.createElement('button'); + removeBtn.className = + 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0'; + removeBtn.innerHTML = ''; + removeBtn.onclick = () => { + state.files = state.files.filter((_: File, i: number) => i !== index); + updateUI(); + }; - fileDiv.append(infoContainer, removeBtn); - fileDisplayArea.appendChild(fileDiv); + fileDiv.append(infoContainer, removeBtn); + fileDisplayArea.appendChild(fileDiv); - try { - const arrayBuffer = await readFileAsArrayBuffer(file); - const pdfDoc = await getPDFDocument({ data: arrayBuffer }).promise; - metaSpan.textContent = `${formatBytes(file.size)} • ${pdfDoc.numPages} pages`; - } catch (error) { - metaSpan.textContent = `${formatBytes(file.size)} • Could not load page count`; - } - } - - createIcons({ icons }); - fileControls.classList.remove('hidden'); - convertOptions.classList.remove('hidden'); - (processBtn as HTMLButtonElement).disabled = false; - } else { - fileDisplayArea.innerHTML = ''; - fileControls.classList.add('hidden'); - convertOptions.classList.add('hidden'); - (processBtn as HTMLButtonElement).disabled = true; - } - }; - - const resetState = () => { - state.files = []; - state.pdfDoc = null; - updateUI(); - }; - - const convert = async () => { try { - if (state.files.length === 0) { - showAlert('No Files', 'Please select at least one PDF file.'); - return; - } - - showLoader('Loading PDF converter...'); - await pymupdf.load(); - - const includeImages = includeImagesCheckbox?.checked ?? false; - - if (state.files.length === 1) { - const file = state.files[0]; - showLoader(`Converting ${file.name}...`); - - const markdown = await pymupdf.pdfToMarkdown(file, { includeImages }); - const outName = file.name.replace(/\.pdf$/i, '') + '.md'; - const blob = new Blob([markdown], { type: 'text/markdown' }); - - downloadFile(blob, outName); - hideLoader(); - - showAlert( - 'Conversion Complete', - `Successfully converted ${file.name} to Markdown.`, - 'success', - () => resetState() - ); - } else { - showLoader('Converting multiple PDFs...'); - const JSZip = (await import('jszip')).default; - const zip = new JSZip(); - - for (let i = 0; i < state.files.length; i++) { - const file = state.files[i]; - showLoader(`Converting ${i + 1}/${state.files.length}: ${file.name}...`); - - const markdown = await pymupdf.pdfToMarkdown(file, { includeImages }); - const baseName = file.name.replace(/\.pdf$/i, ''); - zip.file(`${baseName}.md`, markdown); - } - - showLoader('Creating ZIP archive...'); - const zipBlob = await zip.generateAsync({ type: 'blob' }); - - downloadFile(zipBlob, 'markdown-files.zip'); - hideLoader(); - - showAlert( - 'Conversion Complete', - `Successfully converted ${state.files.length} PDF(s) to Markdown.`, - 'success', - () => resetState() - ); - } - } catch (e: any) { - hideLoader(); - showAlert('Error', `An error occurred during conversion. Error: ${e.message}`); + const arrayBuffer = await readFileAsArrayBuffer(file); + const pdfDoc = await getPDFDocument({ data: arrayBuffer }).promise; + metaSpan.textContent = `${formatBytes(file.size)} • ${pdfDoc.numPages} pages`; + } catch (error) { + metaSpan.textContent = `${formatBytes(file.size)} • Could not load page count`; } - }; + } - const handleFileSelect = (files: FileList | null) => { - if (files && files.length > 0) { - const pdfFiles = Array.from(files).filter( - f => f.type === 'application/pdf' || f.name.toLowerCase().endsWith('.pdf') - ); - state.files = [...state.files, ...pdfFiles]; - updateUI(); + createIcons({ icons }); + fileControls.classList.remove('hidden'); + convertOptions.classList.remove('hidden'); + (processBtn as HTMLButtonElement).disabled = false; + } else { + fileDisplayArea.innerHTML = ''; + fileControls.classList.add('hidden'); + convertOptions.classList.add('hidden'); + (processBtn as HTMLButtonElement).disabled = true; + } + }; + + const resetState = () => { + state.files = []; + state.pdfDoc = null; + updateUI(); + }; + + const convert = async () => { + try { + if (state.files.length === 0) { + showAlert('No Files', 'Please select at least one PDF file.'); + return; + } + + showLoader('Loading PDF converter...'); + const pymupdf = await loadPyMuPDF(); + + const includeImages = includeImagesCheckbox?.checked ?? false; + + if (state.files.length === 1) { + const file = state.files[0]; + showLoader(`Converting ${file.name}...`); + + const markdown = await pymupdf.pdfToMarkdown(file, { includeImages }); + const outName = file.name.replace(/\.pdf$/i, '') + '.md'; + const blob = new Blob([markdown], { type: 'text/markdown' }); + + downloadFile(blob, outName); + hideLoader(); + + showAlert( + 'Conversion Complete', + `Successfully converted ${file.name} to Markdown.`, + 'success', + () => resetState() + ); + } else { + showLoader('Converting multiple PDFs...'); + const JSZip = (await import('jszip')).default; + const zip = new JSZip(); + + for (let i = 0; i < state.files.length; i++) { + const file = state.files[i]; + showLoader( + `Converting ${i + 1}/${state.files.length}: ${file.name}...` + ); + + const markdown = await pymupdf.pdfToMarkdown(file, { includeImages }); + const baseName = file.name.replace(/\.pdf$/i, ''); + zip.file(`${baseName}.md`, markdown); } - }; - if (fileInput && dropZone) { - fileInput.addEventListener('change', (e) => { - handleFileSelect((e.target as HTMLInputElement).files); - }); + showLoader('Creating ZIP archive...'); + const zipBlob = await zip.generateAsync({ type: 'blob' }); - dropZone.addEventListener('dragover', (e) => { - e.preventDefault(); - dropZone.classList.add('bg-gray-700'); - }); + downloadFile(zipBlob, 'markdown-files.zip'); + hideLoader(); - dropZone.addEventListener('dragleave', (e) => { - e.preventDefault(); - dropZone.classList.remove('bg-gray-700'); - }); - - dropZone.addEventListener('drop', (e) => { - e.preventDefault(); - dropZone.classList.remove('bg-gray-700'); - const files = e.dataTransfer?.files; - if (files && files.length > 0) { - handleFileSelect(files); - } - }); - - fileInput.addEventListener('click', () => { - fileInput.value = ''; - }); + showAlert( + 'Conversion Complete', + `Successfully converted ${state.files.length} PDF(s) to Markdown.`, + 'success', + () => resetState() + ); + } + } catch (e: any) { + hideLoader(); + showAlert( + 'Error', + `An error occurred during conversion. Error: ${e.message}` + ); } + }; - if (addMoreBtn) { - addMoreBtn.addEventListener('click', () => { - fileInput.click(); - }); + const handleFileSelect = (files: FileList | null) => { + if (files && files.length > 0) { + const pdfFiles = Array.from(files).filter( + (f) => + f.type === 'application/pdf' || f.name.toLowerCase().endsWith('.pdf') + ); + state.files = [...state.files, ...pdfFiles]; + updateUI(); } + }; - if (clearFilesBtn) { - clearFilesBtn.addEventListener('click', () => { - resetState(); - }); - } + if (fileInput && dropZone) { + fileInput.addEventListener('change', (e) => { + handleFileSelect((e.target as HTMLInputElement).files); + }); - if (processBtn) { - processBtn.addEventListener('click', convert); - } + dropZone.addEventListener('dragover', (e) => { + e.preventDefault(); + dropZone.classList.add('bg-gray-700'); + }); + + dropZone.addEventListener('dragleave', (e) => { + e.preventDefault(); + dropZone.classList.remove('bg-gray-700'); + }); + + dropZone.addEventListener('drop', (e) => { + e.preventDefault(); + dropZone.classList.remove('bg-gray-700'); + const files = e.dataTransfer?.files; + if (files && files.length > 0) { + handleFileSelect(files); + } + }); + + fileInput.addEventListener('click', () => { + fileInput.value = ''; + }); + } + + if (addMoreBtn) { + addMoreBtn.addEventListener('click', () => { + fileInput.click(); + }); + } + + if (clearFilesBtn) { + clearFilesBtn.addEventListener('click', () => { + resetState(); + }); + } + + if (processBtn) { + processBtn.addEventListener('click', convert); + } }); diff --git a/src/js/logic/pdf-to-pdfa-page.ts b/src/js/logic/pdf-to-pdfa-page.ts index 5f392e4..0de4073 100644 --- a/src/js/logic/pdf-to-pdfa-page.ts +++ b/src/js/logic/pdf-to-pdfa-page.ts @@ -1,228 +1,270 @@ import { showLoader, hideLoader, showAlert } from '../ui.js'; import { - downloadFile, - readFileAsArrayBuffer, - formatBytes, - getPDFDocument, + downloadFile, + readFileAsArrayBuffer, + formatBytes, + getPDFDocument, } from '../utils/helpers.js'; import { state } from '../state.js'; import { createIcons, icons } from 'lucide'; import { convertFileToPdfA, type PdfALevel } from '../utils/ghostscript-loader'; +import { loadPyMuPDF, isPyMuPDFAvailable } from '../utils/pymupdf-loader.js'; +import { showWasmRequiredDialog } from '../utils/wasm-provider.js'; document.addEventListener('DOMContentLoaded', () => { - const fileInput = document.getElementById('file-input') as HTMLInputElement; - const dropZone = document.getElementById('drop-zone'); - const processBtn = document.getElementById('process-btn'); - const fileDisplayArea = document.getElementById('file-display-area'); - const optionsContainer = document.getElementById('options-container'); - const fileControls = document.getElementById('file-controls'); - const addMoreBtn = document.getElementById('add-more-btn'); - const clearFilesBtn = document.getElementById('clear-files-btn'); - const backBtn = document.getElementById('back-to-tools'); - const pdfaLevelSelect = document.getElementById('pdfa-level') as HTMLSelectElement; + const fileInput = document.getElementById('file-input') as HTMLInputElement; + const dropZone = document.getElementById('drop-zone'); + const processBtn = document.getElementById('process-btn'); + const fileDisplayArea = document.getElementById('file-display-area'); + const optionsContainer = document.getElementById('options-container'); + const fileControls = document.getElementById('file-controls'); + const addMoreBtn = document.getElementById('add-more-btn'); + const clearFilesBtn = document.getElementById('clear-files-btn'); + const backBtn = document.getElementById('back-to-tools'); + const pdfaLevelSelect = document.getElementById( + 'pdfa-level' + ) as HTMLSelectElement; - if (backBtn) { - backBtn.addEventListener('click', () => { - window.location.href = import.meta.env.BASE_URL; - }); - } + if (backBtn) { + backBtn.addEventListener('click', () => { + window.location.href = import.meta.env.BASE_URL; + }); + } - const updateUI = async () => { - if (!fileDisplayArea || !optionsContainer || !processBtn || !fileControls) return; + const updateUI = async () => { + if (!fileDisplayArea || !optionsContainer || !processBtn || !fileControls) + return; - if (state.files.length > 0) { - fileDisplayArea.innerHTML = ''; + if (state.files.length > 0) { + fileDisplayArea.innerHTML = ''; - for (let index = 0; index < state.files.length; index++) { - const file = state.files[index]; - const fileDiv = document.createElement('div'); - fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm'; + for (let index = 0; index < state.files.length; index++) { + const file = state.files[index]; + const fileDiv = document.createElement('div'); + fileDiv.className = + 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm'; - const infoContainer = document.createElement('div'); - infoContainer.className = 'flex flex-col overflow-hidden'; + const infoContainer = document.createElement('div'); + infoContainer.className = 'flex flex-col overflow-hidden'; - const nameSpan = document.createElement('div'); - nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1'; - nameSpan.textContent = file.name; + const nameSpan = document.createElement('div'); + nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1'; + nameSpan.textContent = file.name; - const metaSpan = document.createElement('div'); - metaSpan.className = 'text-xs text-gray-400'; - metaSpan.textContent = `${formatBytes(file.size)} • Loading pages...`; + const metaSpan = document.createElement('div'); + metaSpan.className = 'text-xs text-gray-400'; + metaSpan.textContent = `${formatBytes(file.size)} • Loading pages...`; - infoContainer.append(nameSpan, metaSpan); + infoContainer.append(nameSpan, metaSpan); - const removeBtn = document.createElement('button'); - removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0'; - removeBtn.innerHTML = ''; - removeBtn.onclick = () => { - state.files = state.files.filter((_, i) => i !== index); - updateUI(); - }; + const removeBtn = document.createElement('button'); + removeBtn.className = + 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0'; + removeBtn.innerHTML = ''; + removeBtn.onclick = () => { + state.files = state.files.filter((_, i) => i !== index); + updateUI(); + }; - fileDiv.append(infoContainer, removeBtn); - fileDisplayArea.appendChild(fileDiv); - - try { - const arrayBuffer = await readFileAsArrayBuffer(file); - const pdfDoc = await getPDFDocument({ data: arrayBuffer }).promise; - metaSpan.textContent = `${formatBytes(file.size)} • ${pdfDoc.numPages} pages`; - } catch (error) { - console.error('Error loading PDF:', error); - metaSpan.textContent = `${formatBytes(file.size)} • Could not load page count`; - } - } - - createIcons({ icons }); - fileControls.classList.remove('hidden'); - optionsContainer.classList.remove('hidden'); - (processBtn as HTMLButtonElement).disabled = false; - } else { - fileDisplayArea.innerHTML = ''; - fileControls.classList.add('hidden'); - optionsContainer.classList.add('hidden'); - (processBtn as HTMLButtonElement).disabled = true; - } - }; - - const resetState = () => { - state.files = []; - state.pdfDoc = null; - - if (pdfaLevelSelect) pdfaLevelSelect.value = 'PDF/A-2b'; - - updateUI(); - }; - - const convertToPdfA = async () => { - const level = pdfaLevelSelect.value as PdfALevel; + fileDiv.append(infoContainer, removeBtn); + fileDisplayArea.appendChild(fileDiv); try { - if (state.files.length === 0) { - showAlert('No Files', 'Please select at least one PDF file.'); - hideLoader(); - return; - } + const arrayBuffer = await readFileAsArrayBuffer(file); + const pdfDoc = await getPDFDocument({ data: arrayBuffer }).promise; + metaSpan.textContent = `${formatBytes(file.size)} • ${pdfDoc.numPages} pages`; + } catch (error) { + console.error('Error loading PDF:', error); + metaSpan.textContent = `${formatBytes(file.size)} • Could not load page count`; + } + } - if (state.files.length === 1) { - const originalFile = state.files[0]; + createIcons({ icons }); + fileControls.classList.remove('hidden'); + optionsContainer.classList.remove('hidden'); + (processBtn as HTMLButtonElement).disabled = false; + } else { + fileDisplayArea.innerHTML = ''; + fileControls.classList.add('hidden'); + optionsContainer.classList.add('hidden'); + (processBtn as HTMLButtonElement).disabled = true; + } + }; - showLoader('Initializing Ghostscript...'); + const resetState = () => { + state.files = []; + state.pdfDoc = null; - const convertedBlob = await convertFileToPdfA( - originalFile, - level, - (msg) => showLoader(msg) - ); + if (pdfaLevelSelect) pdfaLevelSelect.value = 'PDF/A-2b'; - const fileName = originalFile.name.replace(/\.pdf$/i, '') + '_pdfa.pdf'; + updateUI(); + }; - downloadFile(convertedBlob, fileName); + const convertToPdfA = async () => { + const level = pdfaLevelSelect.value as PdfALevel; - hideLoader(); + try { + if (state.files.length === 0) { + showAlert('No Files', 'Please select at least one PDF file.'); + hideLoader(); + return; + } - showAlert( - 'Conversion Complete', - `Successfully converted ${originalFile.name} to ${level}.`, - 'success', - () => resetState() - ); - } else { - showLoader('Converting multiple PDFs to PDF/A...'); - const JSZip = (await import('jszip')).default; - const zip = new JSZip(); + if (state.files.length === 1) { + const originalFile = state.files[0]; + const preFlattenCheckbox = document.getElementById( + 'pre-flatten' + ) as HTMLInputElement; + const shouldPreFlatten = preFlattenCheckbox?.checked || false; - for (let i = 0; i < state.files.length; i++) { - const file = state.files[i]; - showLoader(`Converting ${i + 1}/${state.files.length}: ${file.name}...`); + let fileToConvert = originalFile; - const convertedBlob = await convertFileToPdfA( - file, - level, - (msg) => showLoader(msg) - ); - - const baseName = file.name.replace(/\.pdf$/i, ''); - const blobBuffer = await convertedBlob.arrayBuffer(); - zip.file(`${baseName}_pdfa.pdf`, blobBuffer); - } - - const zipBlob = await zip.generateAsync({ type: 'blob' }); - - downloadFile(zipBlob, 'pdfa-converted.zip'); - - hideLoader(); - - showAlert( - 'Conversion Complete', - `Successfully converted ${state.files.length} PDF(s) to ${level}.`, - 'success', - () => resetState() - ); - } - } catch (e: any) { + // Pre-flatten using PyMuPDF rasterization if checkbox is checked + if (shouldPreFlatten) { + if (!isPyMuPDFAvailable()) { + showWasmRequiredDialog('pymupdf'); hideLoader(); - showAlert( - 'Error', - `An error occurred during conversion. Error: ${e.message}` - ); - } - }; + return; + } - const handleFileSelect = (files: FileList | null) => { - if (files && files.length > 0) { - state.files = [...state.files, ...Array.from(files)]; - updateUI(); - } - }; + showLoader('Pre-flattening PDF...'); + const pymupdf = await loadPyMuPDF(); - if (fileInput && dropZone) { - fileInput.addEventListener('change', (e) => { - handleFileSelect((e.target as HTMLInputElement).files); - }); - - dropZone.addEventListener('dragover', (e) => { - e.preventDefault(); - dropZone.classList.add('bg-gray-700'); - }); - - dropZone.addEventListener('dragleave', (e) => { - e.preventDefault(); - dropZone.classList.remove('bg-gray-700'); - }); - - dropZone.addEventListener('drop', (e) => { - e.preventDefault(); - dropZone.classList.remove('bg-gray-700'); - const files = e.dataTransfer?.files; - if (files && files.length > 0) { - const pdfFiles = Array.from(files).filter(f => f.type === 'application/pdf' || f.name.toLowerCase().endsWith('.pdf')); - if (pdfFiles.length > 0) { - const dataTransfer = new DataTransfer(); - pdfFiles.forEach(f => dataTransfer.items.add(f)); - handleFileSelect(dataTransfer.files); - } + // Rasterize PDF to images and back to PDF (300 DPI for quality) + const flattenedBlob = await (pymupdf as any).rasterizePdf( + originalFile, + { + dpi: 300, + format: 'png', } - }); + ); - // Clear value on click to allow re-selecting the same file - fileInput.addEventListener('click', () => { - fileInput.value = ''; - }); - } + fileToConvert = new File([flattenedBlob], originalFile.name, { + type: 'application/pdf', + }); + } - if (addMoreBtn) { - addMoreBtn.addEventListener('click', () => { - fileInput.click(); - }); - } + showLoader('Initializing Ghostscript...'); - if (clearFilesBtn) { - clearFilesBtn.addEventListener('click', () => { - resetState(); - }); - } + const convertedBlob = await convertFileToPdfA( + fileToConvert, + level, + (msg) => showLoader(msg) + ); - if (processBtn) { - processBtn.addEventListener('click', convertToPdfA); + const fileName = originalFile.name.replace(/\.pdf$/i, '') + '_pdfa.pdf'; + + downloadFile(convertedBlob, fileName); + + hideLoader(); + + showAlert( + 'Conversion Complete', + `Successfully converted ${originalFile.name} to ${level}.`, + 'success', + () => resetState() + ); + } else { + showLoader('Converting multiple PDFs to PDF/A...'); + const JSZip = (await import('jszip')).default; + const zip = new JSZip(); + + for (let i = 0; i < state.files.length; i++) { + const file = state.files[i]; + showLoader( + `Converting ${i + 1}/${state.files.length}: ${file.name}...` + ); + + const convertedBlob = await convertFileToPdfA(file, level, (msg) => + showLoader(msg) + ); + + const baseName = file.name.replace(/\.pdf$/i, ''); + const blobBuffer = await convertedBlob.arrayBuffer(); + zip.file(`${baseName}_pdfa.pdf`, blobBuffer); + } + + const zipBlob = await zip.generateAsync({ type: 'blob' }); + + downloadFile(zipBlob, 'pdfa-converted.zip'); + + hideLoader(); + + showAlert( + 'Conversion Complete', + `Successfully converted ${state.files.length} PDF(s) to ${level}.`, + 'success', + () => resetState() + ); + } + } catch (e: any) { + hideLoader(); + showAlert( + 'Error', + `An error occurred during conversion. Error: ${e.message}` + ); } + }; + + const handleFileSelect = (files: FileList | null) => { + if (files && files.length > 0) { + state.files = [...state.files, ...Array.from(files)]; + updateUI(); + } + }; + + if (fileInput && dropZone) { + fileInput.addEventListener('change', (e) => { + handleFileSelect((e.target as HTMLInputElement).files); + }); + + dropZone.addEventListener('dragover', (e) => { + e.preventDefault(); + dropZone.classList.add('bg-gray-700'); + }); + + dropZone.addEventListener('dragleave', (e) => { + e.preventDefault(); + dropZone.classList.remove('bg-gray-700'); + }); + + dropZone.addEventListener('drop', (e) => { + e.preventDefault(); + dropZone.classList.remove('bg-gray-700'); + const files = e.dataTransfer?.files; + if (files && files.length > 0) { + const pdfFiles = Array.from(files).filter( + (f) => + f.type === 'application/pdf' || + f.name.toLowerCase().endsWith('.pdf') + ); + if (pdfFiles.length > 0) { + const dataTransfer = new DataTransfer(); + pdfFiles.forEach((f) => dataTransfer.items.add(f)); + handleFileSelect(dataTransfer.files); + } + } + }); + + // Clear value on click to allow re-selecting the same file + fileInput.addEventListener('click', () => { + fileInput.value = ''; + }); + } + + if (addMoreBtn) { + addMoreBtn.addEventListener('click', () => { + fileInput.click(); + }); + } + + if (clearFilesBtn) { + clearFilesBtn.addEventListener('click', () => { + resetState(); + }); + } + + if (processBtn) { + processBtn.addEventListener('click', convertToPdfA); + } }); diff --git a/src/js/logic/pdf-to-png-page.ts b/src/js/logic/pdf-to-png-page.ts index f453b65..8f4995d 100644 --- a/src/js/logic/pdf-to-png-page.ts +++ b/src/js/logic/pdf-to-png-page.ts @@ -1,193 +1,231 @@ import { showLoader, hideLoader, showAlert } from '../ui.js'; -import { downloadFile, formatBytes, readFileAsArrayBuffer, getPDFDocument } from '../utils/helpers.js'; +import { + downloadFile, + formatBytes, + readFileAsArrayBuffer, + getPDFDocument, + getCleanPdfFilename, +} from '../utils/helpers.js'; import { createIcons, icons } from 'lucide'; import JSZip from 'jszip'; import * as pdfjsLib from 'pdfjs-dist'; +import { PDFPageProxy } from 'pdfjs-dist'; -pdfjsLib.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).toString(); +pdfjsLib.GlobalWorkerOptions.workerSrc = new URL( + 'pdfjs-dist/build/pdf.worker.min.mjs', + import.meta.url +).toString(); let files: File[] = []; const updateUI = () => { - const fileDisplayArea = document.getElementById('file-display-area'); - const optionsPanel = document.getElementById('options-panel'); - const dropZone = document.getElementById('drop-zone'); + const fileDisplayArea = document.getElementById('file-display-area'); + const optionsPanel = document.getElementById('options-panel'); + const dropZone = document.getElementById('drop-zone'); - if (!fileDisplayArea || !optionsPanel || !dropZone) return; + if (!fileDisplayArea || !optionsPanel || !dropZone) return; - fileDisplayArea.innerHTML = ''; + fileDisplayArea.innerHTML = ''; - if (files.length > 0) { - optionsPanel.classList.remove('hidden'); + if (files.length > 0) { + optionsPanel.classList.remove('hidden'); - files.forEach((file) => { - const fileDiv = document.createElement('div'); - fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm'; + files.forEach((file) => { + const fileDiv = document.createElement('div'); + fileDiv.className = + 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm'; - const infoContainer = document.createElement('div'); - infoContainer.className = 'flex flex-col overflow-hidden'; + const infoContainer = document.createElement('div'); + infoContainer.className = 'flex flex-col overflow-hidden'; - const nameSpan = document.createElement('div'); - nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1'; - nameSpan.textContent = file.name; + const nameSpan = document.createElement('div'); + nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1'; + nameSpan.textContent = file.name; - const metaSpan = document.createElement('div'); - metaSpan.className = 'text-xs text-gray-400'; - metaSpan.textContent = `${formatBytes(file.size)} • Loading pages...`; // Initial state + const metaSpan = document.createElement('div'); + metaSpan.className = 'text-xs text-gray-400'; + metaSpan.textContent = `${formatBytes(file.size)} • Loading pages...`; // Initial state - infoContainer.append(nameSpan, metaSpan); + infoContainer.append(nameSpan, metaSpan); - const removeBtn = document.createElement('button'); - removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0'; - removeBtn.innerHTML = ''; - removeBtn.onclick = () => { - files = []; - updateUI(); - }; + const removeBtn = document.createElement('button'); + removeBtn.className = + 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0'; + removeBtn.innerHTML = ''; + removeBtn.onclick = () => { + files = []; + updateUI(); + }; - fileDiv.append(infoContainer, removeBtn); - fileDisplayArea.appendChild(fileDiv); + fileDiv.append(infoContainer, removeBtn); + fileDisplayArea.appendChild(fileDiv); - // Fetch page count asynchronously - readFileAsArrayBuffer(file).then(buffer => { - return getPDFDocument(buffer).promise; - }).then(pdf => { - metaSpan.textContent = `${formatBytes(file.size)} • ${pdf.numPages} page${pdf.numPages !== 1 ? 's' : ''}`; - }).catch(e => { - console.warn('Error loading PDF page count:', e); - metaSpan.textContent = formatBytes(file.size); - }); + // Fetch page count asynchronously + readFileAsArrayBuffer(file) + .then((buffer) => { + return getPDFDocument(buffer).promise; + }) + .then((pdf) => { + metaSpan.textContent = `${formatBytes(file.size)} • ${pdf.numPages} page${pdf.numPages !== 1 ? 's' : ''}`; + }) + .catch((e) => { + console.warn('Error loading PDF page count:', e); + metaSpan.textContent = formatBytes(file.size); }); + }); - // Initialize icons immediately after synchronous render - createIcons({ icons }); - } else { - optionsPanel.classList.add('hidden'); - } + // Initialize icons immediately after synchronous render + createIcons({ icons }); + } else { + optionsPanel.classList.add('hidden'); + } }; const resetState = () => { - files = []; - const fileInput = document.getElementById('file-input') as HTMLInputElement; - if (fileInput) fileInput.value = ''; - const scaleSlider = document.getElementById('png-scale') as HTMLInputElement; - const scaleValue = document.getElementById('png-scale-value'); - if (scaleSlider) scaleSlider.value = '2.0'; - if (scaleValue) scaleValue.textContent = '2.0x'; - updateUI(); + files = []; + const fileInput = document.getElementById('file-input') as HTMLInputElement; + if (fileInput) fileInput.value = ''; + const scaleSlider = document.getElementById('png-scale') as HTMLInputElement; + const scaleValue = document.getElementById('png-scale-value'); + if (scaleSlider) scaleSlider.value = '2.0'; + if (scaleValue) scaleValue.textContent = '2.0x'; + updateUI(); }; async function convert() { - if (files.length === 0) { - showAlert('No File', 'Please upload a PDF file first.'); - return; - } - showLoader('Converting to PNG...'); - try { - const pdf = await getPDFDocument( - await readFileAsArrayBuffer(files[0]) - ).promise; - const zip = new JSZip(); + if (files.length === 0) { + showAlert('No File', 'Please upload a PDF file first.'); + return; + } + showLoader('Converting to PNG...'); + try { + const pdf = await getPDFDocument(await readFileAsArrayBuffer(files[0])) + .promise; - const scaleInput = document.getElementById('png-scale') as HTMLInputElement; - const scale = scaleInput ? parseFloat(scaleInput.value) : 2.0; + const scaleInput = document.getElementById('png-scale') as HTMLInputElement; + const scale = scaleInput ? parseFloat(scaleInput.value) : 2.0; - for (let i = 1; i <= pdf.numPages; i++) { - const page = await pdf.getPage(i); - const viewport = page.getViewport({ scale }); - const canvas = document.createElement('canvas'); - const context = canvas.getContext('2d'); - canvas.height = viewport.height; - canvas.width = viewport.width; - - await page.render({ canvasContext: context!, viewport: viewport, canvas }).promise; - - const blob = await new Promise((resolve) => - canvas.toBlob(resolve, 'image/png') - ); - if (blob) { - zip.file(`page_${i}.png`, blob); - } + if (pdf.numPages === 1) { + const page = await pdf.getPage(1); + const blob = await renderPage(page, scale); + downloadFile(blob, getCleanPdfFilename(files[0].name) + '.png'); + } else { + const zip = new JSZip(); + for (let i = 1; i <= pdf.numPages; i++) { + const page = await pdf.getPage(i); + const blob = await renderPage(page, scale); + if (blob) { + zip.file(`page_${i}.png`, blob); } + } - const zipBlob = await zip.generateAsync({ type: 'blob' }); - downloadFile(zipBlob, 'converted_images.zip'); - showAlert('Success', 'PDF converted to PNGs successfully!', 'success', () => { - resetState(); - }); - } catch (e) { - console.error(e); - showAlert( - 'Error', - 'Failed to convert PDF to PNG. The file might be corrupted.' - ); - } finally { - hideLoader(); + const zipBlob = await zip.generateAsync({ type: 'blob' }); + downloadFile(zipBlob, getCleanPdfFilename(files[0].name) + '_pngs.zip'); } + + showAlert( + 'Success', + 'PDF converted to PNGs successfully!', + 'success', + () => { + resetState(); + } + ); + } catch (e) { + console.error(e); + showAlert( + 'Error', + 'Failed to convert PDF to PNG. The file might be corrupted.' + ); + } finally { + hideLoader(); + } +} + +async function renderPage( + page: PDFPageProxy, + scale: number +): Promise { + const viewport = page.getViewport({ scale }); + const canvas = document.createElement('canvas'); + const context = canvas.getContext('2d'); + canvas.height = viewport.height; + canvas.width = viewport.width; + + await page.render({ + canvasContext: context!, + viewport: viewport, + canvas, + }).promise; + + const blob = await new Promise((resolve) => + canvas.toBlob(resolve, 'image/png') + ); + return blob; } document.addEventListener('DOMContentLoaded', () => { - const fileInput = document.getElementById('file-input') as HTMLInputElement; - const dropZone = document.getElementById('drop-zone'); - const processBtn = document.getElementById('process-btn'); - const backBtn = document.getElementById('back-to-tools'); - const scaleSlider = document.getElementById('png-scale') as HTMLInputElement; - const scaleValue = document.getElementById('png-scale-value'); + const fileInput = document.getElementById('file-input') as HTMLInputElement; + const dropZone = document.getElementById('drop-zone'); + const processBtn = document.getElementById('process-btn'); + const backBtn = document.getElementById('back-to-tools'); + const scaleSlider = document.getElementById('png-scale') as HTMLInputElement; + const scaleValue = document.getElementById('png-scale-value'); - if (backBtn) { - backBtn.addEventListener('click', () => { - window.location.href = import.meta.env.BASE_URL; - }); + if (backBtn) { + backBtn.addEventListener('click', () => { + window.location.href = import.meta.env.BASE_URL; + }); + } + + if (scaleSlider && scaleValue) { + scaleSlider.addEventListener('input', () => { + scaleValue.textContent = `${parseFloat(scaleSlider.value).toFixed(1)}x`; + }); + } + + const handleFileSelect = (newFiles: FileList | null) => { + if (!newFiles || newFiles.length === 0) return; + const validFiles = Array.from(newFiles).filter( + (file) => file.type === 'application/pdf' + ); + + if (validFiles.length === 0) { + showAlert('Invalid File', 'Please upload a PDF file.'); + return; } - if (scaleSlider && scaleValue) { - scaleSlider.addEventListener('input', () => { - scaleValue.textContent = `${parseFloat(scaleSlider.value).toFixed(1)}x`; - }); - } + files = [validFiles[0]]; + updateUI(); + }; - const handleFileSelect = (newFiles: FileList | null) => { - if (!newFiles || newFiles.length === 0) return; - const validFiles = Array.from(newFiles).filter( - (file) => file.type === 'application/pdf' - ); + if (fileInput && dropZone) { + fileInput.addEventListener('change', (e) => { + handleFileSelect((e.target as HTMLInputElement).files); + }); - if (validFiles.length === 0) { - showAlert('Invalid File', 'Please upload a PDF file.'); - return; - } + dropZone.addEventListener('dragover', (e) => { + e.preventDefault(); + dropZone.classList.add('bg-gray-700'); + }); - files = [validFiles[0]]; - updateUI(); - }; + dropZone.addEventListener('dragleave', (e) => { + e.preventDefault(); + dropZone.classList.remove('bg-gray-700'); + }); - if (fileInput && dropZone) { - fileInput.addEventListener('change', (e) => { - handleFileSelect((e.target as HTMLInputElement).files); - }); + dropZone.addEventListener('drop', (e) => { + e.preventDefault(); + dropZone.classList.remove('bg-gray-700'); + handleFileSelect(e.dataTransfer?.files ?? null); + }); - dropZone.addEventListener('dragover', (e) => { - e.preventDefault(); - dropZone.classList.add('bg-gray-700'); - }); + fileInput.addEventListener('click', () => { + fileInput.value = ''; + }); + } - dropZone.addEventListener('dragleave', (e) => { - e.preventDefault(); - dropZone.classList.remove('bg-gray-700'); - }); - - dropZone.addEventListener('drop', (e) => { - e.preventDefault(); - dropZone.classList.remove('bg-gray-700'); - handleFileSelect(e.dataTransfer?.files ?? null); - }); - - fileInput.addEventListener('click', () => { - fileInput.value = ''; - }); - } - - if (processBtn) { - processBtn.addEventListener('click', convert); - } + if (processBtn) { + processBtn.addEventListener('click', convert); + } }); diff --git a/src/js/logic/pdf-to-svg-page.ts b/src/js/logic/pdf-to-svg-page.ts index 6813e24..6bae490 100644 --- a/src/js/logic/pdf-to-svg-page.ts +++ b/src/js/logic/pdf-to-svg-page.ts @@ -2,201 +2,237 @@ import { showLoader, hideLoader, showAlert } from '../ui.js'; import { downloadFile, formatBytes } from '../utils/helpers.js'; import { createIcons, icons } from 'lucide'; import JSZip from 'jszip'; -import { PyMuPDF } from '@bentopdf/pymupdf-wasm'; -import { getWasmBaseUrl } from '../config/wasm-cdn-config.js'; +import { isWasmAvailable, getWasmBaseUrl } from '../config/wasm-cdn-config.js'; +import { showWasmRequiredDialog } from '../utils/wasm-provider.js'; +import { loadPyMuPDF, isPyMuPDFAvailable } from '../utils/pymupdf-loader.js'; -const pymupdf = new PyMuPDF(getWasmBaseUrl('pymupdf')); +let pymupdf: any = null; let files: File[] = []; const updateUI = () => { - const fileDisplayArea = document.getElementById('file-display-area'); - const optionsPanel = document.getElementById('options-panel'); - const fileControls = document.getElementById('file-controls'); + const fileDisplayArea = document.getElementById('file-display-area'); + const optionsPanel = document.getElementById('options-panel'); + const fileControls = document.getElementById('file-controls'); - if (!fileDisplayArea || !optionsPanel) return; + if (!fileDisplayArea || !optionsPanel) return; - fileDisplayArea.innerHTML = ''; + fileDisplayArea.innerHTML = ''; - if (files.length > 0) { - optionsPanel.classList.remove('hidden'); - if (fileControls) fileControls.classList.remove('hidden'); + if (files.length > 0) { + optionsPanel.classList.remove('hidden'); + if (fileControls) fileControls.classList.remove('hidden'); - files.forEach((file, index) => { - const fileDiv = document.createElement('div'); - fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm'; + files.forEach((file, index) => { + const fileDiv = document.createElement('div'); + fileDiv.className = + 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm'; - const infoContainer = document.createElement('div'); - infoContainer.className = 'flex flex-col overflow-hidden'; + const infoContainer = document.createElement('div'); + infoContainer.className = 'flex flex-col overflow-hidden'; - const nameSpan = document.createElement('div'); - nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1'; - nameSpan.textContent = file.name; + const nameSpan = document.createElement('div'); + nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1'; + nameSpan.textContent = file.name; - const metaSpan = document.createElement('div'); - metaSpan.className = 'text-xs text-gray-400'; - metaSpan.textContent = formatBytes(file.size); + const metaSpan = document.createElement('div'); + metaSpan.className = 'text-xs text-gray-400'; + metaSpan.textContent = formatBytes(file.size); - infoContainer.append(nameSpan, metaSpan); + infoContainer.append(nameSpan, metaSpan); - const removeBtn = document.createElement('button'); - removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0'; - removeBtn.innerHTML = ''; - removeBtn.onclick = () => { - files = files.filter((_, i) => i !== index); - updateUI(); - }; + const removeBtn = document.createElement('button'); + removeBtn.className = + 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0'; + removeBtn.innerHTML = ''; + removeBtn.onclick = () => { + files = files.filter((_, i) => i !== index); + updateUI(); + }; - fileDiv.append(infoContainer, removeBtn); - fileDisplayArea.appendChild(fileDiv); - }); + fileDiv.append(infoContainer, removeBtn); + fileDisplayArea.appendChild(fileDiv); + }); - createIcons({ icons }); - } else { - optionsPanel.classList.add('hidden'); - if (fileControls) fileControls.classList.add('hidden'); - } + createIcons({ icons }); + } else { + optionsPanel.classList.add('hidden'); + if (fileControls) fileControls.classList.add('hidden'); + } }; const resetState = () => { - files = []; - const fileInput = document.getElementById('file-input') as HTMLInputElement; - if (fileInput) fileInput.value = ''; - updateUI(); + files = []; + const fileInput = document.getElementById('file-input') as HTMLInputElement; + if (fileInput) fileInput.value = ''; + updateUI(); }; async function convert() { - if (files.length === 0) { - showAlert('No Files', 'Please upload at least one PDF file.'); - return; + if (files.length === 0) { + showAlert('No Files', 'Please upload at least one PDF file.'); + return; + } + + // Check if PyMuPDF is configured + if (!isPyMuPDFAvailable()) { + showWasmRequiredDialog('pymupdf'); + return; + } + + showLoader('Loading Engine...'); + + try { + // Load PyMuPDF dynamically if not already loaded + if (!pymupdf) { + pymupdf = await loadPyMuPDF(); } - showLoader('Loading Engine...'); + const isSingleFile = files.length === 1; - try { - await pymupdf.load(); + if (isSingleFile) { + const doc = await pymupdf.open(files[0]); + const pageCount = doc.pageCount; + const baseName = files[0].name.replace(/\.[^/.]+$/, ''); - const isSingleFile = files.length === 1; - - if (isSingleFile) { - const doc = await pymupdf.open(files[0]); - const pageCount = doc.pageCount; - const baseName = files[0].name.replace(/\.[^/.]+$/, ''); - - if (pageCount === 1) { - showLoader('Converting to SVG...'); - const page = doc.getPage(0); - const svgContent = page.toSvg(); - const svgBlob = new Blob([svgContent], { type: 'image/svg+xml' }); - downloadFile(svgBlob, `${baseName}.svg`); - showAlert('Success', 'PDF converted to SVG successfully!', 'success', () => resetState()); - } else { - const zip = new JSZip(); - for (let i = 0; i < pageCount; i++) { - showLoader(`Converting page ${i + 1} of ${pageCount}...`); - const page = doc.getPage(i); - const svgContent = page.toSvg(); - zip.file(`page_${i + 1}.svg`, svgContent); - } - showLoader('Creating ZIP file...'); - const zipBlob = await zip.generateAsync({ type: 'blob' }); - downloadFile(zipBlob, `${baseName}_svg.zip`); - showAlert('Success', `Converted ${pageCount} pages to SVG!`, 'success', () => resetState()); - } - } else { - const zip = new JSZip(); - let totalPages = 0; - - for (let f = 0; f < files.length; f++) { - const file = files[f]; - showLoader(`Processing file ${f + 1} of ${files.length}...`); - const doc = await pymupdf.open(file); - const pageCount = doc.pageCount; - const baseName = file.name.replace(/\.[^/.]+$/, ''); - - for (let i = 0; i < pageCount; i++) { - showLoader(`File ${f + 1}/${files.length}: Page ${i + 1}/${pageCount}`); - const page = doc.getPage(i); - const svgContent = page.toSvg(); - const fileName = pageCount === 1 ? `${baseName}.svg` : `${baseName}_page_${i + 1}.svg`; - zip.file(fileName, svgContent); - totalPages++; - } - } - - showLoader('Creating ZIP file...'); - const zipBlob = await zip.generateAsync({ type: 'blob' }); - downloadFile(zipBlob, 'pdf_to_svg.zip'); - showAlert('Success', `Converted ${files.length} files (${totalPages} pages) to SVG!`, 'success', () => resetState()); + if (pageCount === 1) { + showLoader('Converting to SVG...'); + const page = doc.getPage(0); + const svgContent = page.toSvg(); + const svgBlob = new Blob([svgContent], { type: 'image/svg+xml' }); + downloadFile(svgBlob, `${baseName}.svg`); + showAlert( + 'Success', + 'PDF converted to SVG successfully!', + 'success', + () => resetState() + ); + } else { + const zip = new JSZip(); + for (let i = 0; i < pageCount; i++) { + showLoader(`Converting page ${i + 1} of ${pageCount}...`); + const page = doc.getPage(i); + const svgContent = page.toSvg(); + zip.file(`page_${i + 1}.svg`, svgContent); } - } catch (e) { - console.error(e); - const message = e instanceof Error ? e.message : 'Unknown error'; - showAlert('Error', `Failed to convert PDF to SVG. ${message}`); - } finally { - hideLoader(); + showLoader('Creating ZIP file...'); + const zipBlob = await zip.generateAsync({ type: 'blob' }); + downloadFile(zipBlob, `${baseName}_svg.zip`); + showAlert( + 'Success', + `Converted ${pageCount} pages to SVG!`, + 'success', + () => resetState() + ); + } + } else { + const zip = new JSZip(); + let totalPages = 0; + + for (let f = 0; f < files.length; f++) { + const file = files[f]; + showLoader(`Processing file ${f + 1} of ${files.length}...`); + const doc = await pymupdf.open(file); + const pageCount = doc.pageCount; + const baseName = file.name.replace(/\.[^/.]+$/, ''); + + for (let i = 0; i < pageCount; i++) { + showLoader( + `File ${f + 1}/${files.length}: Page ${i + 1}/${pageCount}` + ); + const page = doc.getPage(i); + const svgContent = page.toSvg(); + const fileName = + pageCount === 1 + ? `${baseName}.svg` + : `${baseName}_page_${i + 1}.svg`; + zip.file(fileName, svgContent); + totalPages++; + } + } + + showLoader('Creating ZIP file...'); + const zipBlob = await zip.generateAsync({ type: 'blob' }); + downloadFile(zipBlob, 'pdf_to_svg.zip'); + showAlert( + 'Success', + `Converted ${files.length} files (${totalPages} pages) to SVG!`, + 'success', + () => resetState() + ); } + } catch (e) { + console.error(e); + const message = e instanceof Error ? e.message : 'Unknown error'; + showAlert('Error', `Failed to convert PDF to SVG. ${message}`); + } finally { + hideLoader(); + } } document.addEventListener('DOMContentLoaded', () => { - const fileInput = document.getElementById('file-input') as HTMLInputElement; - const dropZone = document.getElementById('drop-zone'); - const processBtn = document.getElementById('process-btn'); - const backBtn = document.getElementById('back-to-tools'); - const addMoreBtn = document.getElementById('add-more-btn'); - const clearFilesBtn = document.getElementById('clear-files-btn'); + const fileInput = document.getElementById('file-input') as HTMLInputElement; + const dropZone = document.getElementById('drop-zone'); + const processBtn = document.getElementById('process-btn'); + const backBtn = document.getElementById('back-to-tools'); + const addMoreBtn = document.getElementById('add-more-btn'); + const clearFilesBtn = document.getElementById('clear-files-btn'); - if (backBtn) { - backBtn.addEventListener('click', () => { - window.location.href = import.meta.env.BASE_URL; - }); + if (backBtn) { + backBtn.addEventListener('click', () => { + window.location.href = import.meta.env.BASE_URL; + }); + } + + const handleFileSelect = (newFiles: FileList | null, replace = false) => { + if (!newFiles || newFiles.length === 0) return; + const validFiles = Array.from(newFiles).filter( + (file) => file.type === 'application/pdf' + ); + + if (validFiles.length === 0) { + showAlert('Invalid Files', 'Please upload PDF files.'); + return; } - const handleFileSelect = (newFiles: FileList | null, replace = false) => { - if (!newFiles || newFiles.length === 0) return; - const validFiles = Array.from(newFiles).filter( - (file) => file.type === 'application/pdf' - ); - - if (validFiles.length === 0) { - showAlert('Invalid Files', 'Please upload PDF files.'); - return; - } - - if (replace) { - files = validFiles; - } else { - files = [...files, ...validFiles]; - } - updateUI(); - }; - - if (fileInput && dropZone) { - fileInput.addEventListener('change', (e) => { - handleFileSelect((e.target as HTMLInputElement).files, files.length === 0); - }); - - dropZone.addEventListener('dragover', (e) => { - e.preventDefault(); - dropZone.classList.add('bg-gray-700'); - }); - - dropZone.addEventListener('dragleave', (e) => { - e.preventDefault(); - dropZone.classList.remove('bg-gray-700'); - }); - - dropZone.addEventListener('drop', (e) => { - e.preventDefault(); - dropZone.classList.remove('bg-gray-700'); - handleFileSelect(e.dataTransfer?.files ?? null, files.length === 0); - }); - - fileInput.addEventListener('click', () => { - fileInput.value = ''; - }); + if (replace) { + files = validFiles; + } else { + files = [...files, ...validFiles]; } + updateUI(); + }; - if (addMoreBtn) addMoreBtn.addEventListener('click', () => fileInput?.click()); - if (clearFilesBtn) clearFilesBtn.addEventListener('click', resetState); - if (processBtn) processBtn.addEventListener('click', convert); + if (fileInput && dropZone) { + fileInput.addEventListener('change', (e) => { + handleFileSelect( + (e.target as HTMLInputElement).files, + files.length === 0 + ); + }); + + dropZone.addEventListener('dragover', (e) => { + e.preventDefault(); + dropZone.classList.add('bg-gray-700'); + }); + + dropZone.addEventListener('dragleave', (e) => { + e.preventDefault(); + dropZone.classList.remove('bg-gray-700'); + }); + + dropZone.addEventListener('drop', (e) => { + e.preventDefault(); + dropZone.classList.remove('bg-gray-700'); + handleFileSelect(e.dataTransfer?.files ?? null, files.length === 0); + }); + + fileInput.addEventListener('click', () => { + fileInput.value = ''; + }); + } + + if (addMoreBtn) + addMoreBtn.addEventListener('click', () => fileInput?.click()); + if (clearFilesBtn) clearFilesBtn.addEventListener('click', resetState); + if (processBtn) processBtn.addEventListener('click', convert); }); diff --git a/src/js/logic/pdf-to-text-page.ts b/src/js/logic/pdf-to-text-page.ts index 1df7fba..efa00ce 100644 --- a/src/js/logic/pdf-to-text-page.ts +++ b/src/js/logic/pdf-to-text-page.ts @@ -1,212 +1,233 @@ import { createIcons, icons } from 'lucide'; import { showAlert, showLoader, hideLoader } from '../ui.js'; import { downloadFile, formatBytes } from '../utils/helpers.js'; -import { PyMuPDF } from '@bentopdf/pymupdf-wasm'; -import { getWasmBaseUrl } from '../config/wasm-cdn-config.js'; +import { isWasmAvailable, getWasmBaseUrl } from '../config/wasm-cdn-config.js'; +import { showWasmRequiredDialog } from '../utils/wasm-provider.js'; +import { loadPyMuPDF, isPyMuPDFAvailable } from '../utils/pymupdf-loader.js'; let files: File[] = []; -let pymupdf: PyMuPDF | null = null; +let pymupdf: any = null; if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', initializePage); + document.addEventListener('DOMContentLoaded', initializePage); } else { - initializePage(); + initializePage(); } function initializePage() { - createIcons({ icons }); + createIcons({ icons }); - const fileInput = document.getElementById('file-input') as HTMLInputElement; - const dropZone = document.getElementById('drop-zone'); - const addMoreBtn = document.getElementById('add-more-btn'); - const clearFilesBtn = document.getElementById('clear-files-btn'); - const processBtn = document.getElementById('process-btn') as HTMLButtonElement; + const fileInput = document.getElementById('file-input') as HTMLInputElement; + const dropZone = document.getElementById('drop-zone'); + const addMoreBtn = document.getElementById('add-more-btn'); + const clearFilesBtn = document.getElementById('clear-files-btn'); + const processBtn = document.getElementById( + 'process-btn' + ) as HTMLButtonElement; - if (fileInput) { - fileInput.addEventListener('change', handleFileUpload); - } + if (fileInput) { + fileInput.addEventListener('change', handleFileUpload); + } - if (dropZone) { - dropZone.addEventListener('dragover', (e) => { - e.preventDefault(); - dropZone.classList.add('bg-gray-600'); - }); - - dropZone.addEventListener('dragleave', () => { - dropZone.classList.remove('bg-gray-600'); - }); - - dropZone.addEventListener('drop', (e) => { - e.preventDefault(); - dropZone.classList.remove('bg-gray-600'); - const droppedFiles = e.dataTransfer?.files; - if (droppedFiles && droppedFiles.length > 0) { - handleFiles(droppedFiles); - } - }); - - fileInput?.addEventListener('click', () => { - if (fileInput) fileInput.value = ''; - }); - } - - if (addMoreBtn) { - addMoreBtn.addEventListener('click', () => { - fileInput?.click(); - }); - } - - if (clearFilesBtn) { - clearFilesBtn.addEventListener('click', () => { - files = []; - updateUI(); - }); - } - - if (processBtn) { - processBtn.addEventListener('click', extractText); - } - - document.getElementById('back-to-tools')?.addEventListener('click', () => { - window.location.href = import.meta.env.BASE_URL; + if (dropZone) { + dropZone.addEventListener('dragover', (e) => { + e.preventDefault(); + dropZone.classList.add('bg-gray-600'); }); + + dropZone.addEventListener('dragleave', () => { + dropZone.classList.remove('bg-gray-600'); + }); + + dropZone.addEventListener('drop', (e) => { + e.preventDefault(); + dropZone.classList.remove('bg-gray-600'); + const droppedFiles = e.dataTransfer?.files; + if (droppedFiles && droppedFiles.length > 0) { + handleFiles(droppedFiles); + } + }); + + fileInput?.addEventListener('click', () => { + if (fileInput) fileInput.value = ''; + }); + } + + if (addMoreBtn) { + addMoreBtn.addEventListener('click', () => { + fileInput?.click(); + }); + } + + if (clearFilesBtn) { + clearFilesBtn.addEventListener('click', () => { + files = []; + updateUI(); + }); + } + + if (processBtn) { + processBtn.addEventListener('click', extractText); + } + + document.getElementById('back-to-tools')?.addEventListener('click', () => { + window.location.href = import.meta.env.BASE_URL; + }); } function handleFileUpload(e: Event) { - const input = e.target as HTMLInputElement; - if (input.files && input.files.length > 0) { - handleFiles(input.files); - } + const input = e.target as HTMLInputElement; + if (input.files && input.files.length > 0) { + handleFiles(input.files); + } } function handleFiles(newFiles: FileList) { - const validFiles = Array.from(newFiles).filter(file => - file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf') + const validFiles = Array.from(newFiles).filter( + (file) => + file.type === 'application/pdf' || + file.name.toLowerCase().endsWith('.pdf') + ); + + if (validFiles.length < newFiles.length) { + showAlert( + 'Invalid Files', + 'Some files were skipped. Only PDF files are allowed.' ); + } - if (validFiles.length < newFiles.length) { - showAlert('Invalid Files', 'Some files were skipped. Only PDF files are allowed.'); - } - - if (validFiles.length > 0) { - files = [...files, ...validFiles]; - updateUI(); - } + if (validFiles.length > 0) { + files = [...files, ...validFiles]; + updateUI(); + } } const resetState = () => { - files = []; - updateUI(); + files = []; + updateUI(); }; function updateUI() { - const fileDisplayArea = document.getElementById('file-display-area'); - const fileControls = document.getElementById('file-controls'); - const extractOptions = document.getElementById('extract-options'); + const fileDisplayArea = document.getElementById('file-display-area'); + const fileControls = document.getElementById('file-controls'); + const extractOptions = document.getElementById('extract-options'); - if (!fileDisplayArea || !fileControls || !extractOptions) return; + if (!fileDisplayArea || !fileControls || !extractOptions) return; - fileDisplayArea.innerHTML = ''; + fileDisplayArea.innerHTML = ''; - if (files.length > 0) { - fileControls.classList.remove('hidden'); - extractOptions.classList.remove('hidden'); + if (files.length > 0) { + fileControls.classList.remove('hidden'); + extractOptions.classList.remove('hidden'); - files.forEach((file, index) => { - const fileDiv = document.createElement('div'); - fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm'; + files.forEach((file, index) => { + const fileDiv = document.createElement('div'); + fileDiv.className = + 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm'; - const infoContainer = document.createElement('div'); - infoContainer.className = 'flex items-center gap-2 overflow-hidden'; + const infoContainer = document.createElement('div'); + infoContainer.className = 'flex items-center gap-2 overflow-hidden'; - const nameSpan = document.createElement('span'); - nameSpan.className = 'truncate font-medium text-gray-200'; - nameSpan.textContent = file.name; + const nameSpan = document.createElement('span'); + nameSpan.className = 'truncate font-medium text-gray-200'; + nameSpan.textContent = file.name; - const sizeSpan = document.createElement('span'); - sizeSpan.className = 'flex-shrink-0 text-gray-400 text-xs'; - sizeSpan.textContent = `(${formatBytes(file.size)})`; + const sizeSpan = document.createElement('span'); + sizeSpan.className = 'flex-shrink-0 text-gray-400 text-xs'; + sizeSpan.textContent = `(${formatBytes(file.size)})`; - infoContainer.append(nameSpan, sizeSpan); + infoContainer.append(nameSpan, sizeSpan); - const removeBtn = document.createElement('button'); - removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0'; - removeBtn.innerHTML = ''; - removeBtn.onclick = () => { - files = files.filter((_, i) => i !== index); - updateUI(); - }; + const removeBtn = document.createElement('button'); + removeBtn.className = + 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0'; + removeBtn.innerHTML = ''; + removeBtn.onclick = () => { + files = files.filter((_, i) => i !== index); + updateUI(); + }; - fileDiv.append(infoContainer, removeBtn); - fileDisplayArea.appendChild(fileDiv); - }); - createIcons({ icons }); - } else { - fileControls.classList.add('hidden'); - extractOptions.classList.add('hidden'); - } + fileDiv.append(infoContainer, removeBtn); + fileDisplayArea.appendChild(fileDiv); + }); + createIcons({ icons }); + } else { + fileControls.classList.add('hidden'); + extractOptions.classList.add('hidden'); + } } -async function ensurePyMuPDF(): Promise { - if (!pymupdf) { - pymupdf = new PyMuPDF(getWasmBaseUrl('pymupdf')); - await pymupdf.load(); - } - return pymupdf; +async function ensurePyMuPDF(): Promise { + if (!pymupdf) { + pymupdf = await loadPyMuPDF(); + } + return pymupdf; } async function extractText() { - if (files.length === 0) { - showAlert('No Files', 'Please select at least one PDF file.'); - return; - } + if (files.length === 0) { + showAlert('No Files', 'Please select at least one PDF file.'); + return; + } - showLoader('Loading engine...'); + showLoader('Loading engine...'); - try { - const mupdf = await ensurePyMuPDF(); + try { + const mupdf = await ensurePyMuPDF(); - if (files.length === 1) { - const file = files[0]; - showLoader(`Extracting text from ${file.name}...`); + if (files.length === 1) { + const file = files[0]; + showLoader(`Extracting text from ${file.name}...`); - const fullText = await mupdf.pdfToText(file); + const fullText = await mupdf.pdfToText(file); - const baseName = file.name.replace(/\.pdf$/i, ''); - const textBlob = new Blob([fullText], { type: 'text/plain;charset=utf-8' }); - downloadFile(textBlob, `${baseName}.txt`); + const baseName = file.name.replace(/\.pdf$/i, ''); + const textBlob = new Blob([fullText], { + type: 'text/plain;charset=utf-8', + }); + downloadFile(textBlob, `${baseName}.txt`); - hideLoader(); - showAlert('Success', 'Text extracted successfully!', 'success', () => { - resetState(); - }); - } else { - showLoader('Extracting text from multiple files...'); + hideLoader(); + showAlert('Success', 'Text extracted successfully!', 'success', () => { + resetState(); + }); + } else { + showLoader('Extracting text from multiple files...'); - const JSZip = (await import('jszip')).default; - const zip = new JSZip(); + const JSZip = (await import('jszip')).default; + const zip = new JSZip(); - for (let i = 0; i < files.length; i++) { - const file = files[i]; - showLoader(`Extracting text from file ${i + 1}/${files.length}: ${file.name}...`); + for (let i = 0; i < files.length; i++) { + const file = files[i]; + showLoader( + `Extracting text from file ${i + 1}/${files.length}: ${file.name}...` + ); - const fullText = await mupdf.pdfToText(file); + const fullText = await mupdf.pdfToText(file); - const baseName = file.name.replace(/\.pdf$/i, ''); - zip.file(`${baseName}.txt`, fullText); - } + const baseName = file.name.replace(/\.pdf$/i, ''); + zip.file(`${baseName}.txt`, fullText); + } - const zipBlob = await zip.generateAsync({ type: 'blob' }); - downloadFile(zipBlob, 'pdf-to-text.zip'); + const zipBlob = await zip.generateAsync({ type: 'blob' }); + downloadFile(zipBlob, 'pdf-to-text.zip'); - hideLoader(); - showAlert('Success', `Extracted text from ${files.length} PDF files!`, 'success', () => { - resetState(); - }); + hideLoader(); + showAlert( + 'Success', + `Extracted text from ${files.length} PDF files!`, + 'success', + () => { + resetState(); } - } catch (e: any) { - console.error('[PDFToText]', e); - hideLoader(); - showAlert('Extraction Error', e.message || 'Failed to extract text from PDF.'); + ); } + } catch (e: any) { + console.error('[PDFToText]', e); + hideLoader(); + showAlert( + 'Extraction Error', + e.message || 'Failed to extract text from PDF.' + ); + } } diff --git a/src/js/logic/pdf-to-tiff-page.ts b/src/js/logic/pdf-to-tiff-page.ts index d92734d..10ad314 100644 --- a/src/js/logic/pdf-to-tiff-page.ts +++ b/src/js/logic/pdf-to-tiff-page.ts @@ -1,190 +1,251 @@ import { showLoader, hideLoader, showAlert } from '../ui.js'; -import { downloadFile, formatBytes, readFileAsArrayBuffer, getPDFDocument } from '../utils/helpers.js'; +import { + downloadFile, + formatBytes, + readFileAsArrayBuffer, + getPDFDocument, + getCleanPdfFilename, +} from '../utils/helpers.js'; import { createIcons, icons } from 'lucide'; import JSZip from 'jszip'; import * as pdfjsLib from 'pdfjs-dist'; import UTIF from 'utif'; +import { PDFPageProxy } from 'pdfjs-dist'; -pdfjsLib.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).toString(); +pdfjsLib.GlobalWorkerOptions.workerSrc = new URL( + 'pdfjs-dist/build/pdf.worker.min.mjs', + import.meta.url +).toString(); let files: File[] = []; const updateUI = () => { - const fileDisplayArea = document.getElementById('file-display-area'); - const optionsPanel = document.getElementById('options-panel'); - const dropZone = document.getElementById('drop-zone'); + const fileDisplayArea = document.getElementById('file-display-area'); + const optionsPanel = document.getElementById('options-panel'); + const dropZone = document.getElementById('drop-zone'); - if (!fileDisplayArea || !optionsPanel || !dropZone) return; + if (!fileDisplayArea || !optionsPanel || !dropZone) return; - fileDisplayArea.innerHTML = ''; + fileDisplayArea.innerHTML = ''; - if (files.length > 0) { - optionsPanel.classList.remove('hidden'); + if (files.length > 0) { + optionsPanel.classList.remove('hidden'); - files.forEach((file) => { - const fileDiv = document.createElement('div'); - fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm'; + files.forEach((file) => { + const fileDiv = document.createElement('div'); + fileDiv.className = + 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm'; - const infoContainer = document.createElement('div'); - infoContainer.className = 'flex flex-col overflow-hidden'; + const infoContainer = document.createElement('div'); + infoContainer.className = 'flex flex-col overflow-hidden'; - const nameSpan = document.createElement('div'); - nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1'; - nameSpan.textContent = file.name; + const nameSpan = document.createElement('div'); + nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1'; + nameSpan.textContent = file.name; - const metaSpan = document.createElement('div'); - metaSpan.className = 'text-xs text-gray-400'; - metaSpan.textContent = `${formatBytes(file.size)} • Loading pages...`; // Initial state + const metaSpan = document.createElement('div'); + metaSpan.className = 'text-xs text-gray-400'; + metaSpan.textContent = `${formatBytes(file.size)} • Loading pages...`; // Initial state - infoContainer.append(nameSpan, metaSpan); + infoContainer.append(nameSpan, metaSpan); - const removeBtn = document.createElement('button'); - removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0'; - removeBtn.innerHTML = ''; - removeBtn.onclick = () => { - files = []; - updateUI(); - }; + const removeBtn = document.createElement('button'); + removeBtn.className = + 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0'; + removeBtn.innerHTML = ''; + removeBtn.onclick = () => { + files = []; + updateUI(); + }; - fileDiv.append(infoContainer, removeBtn); - fileDisplayArea.appendChild(fileDiv); + fileDiv.append(infoContainer, removeBtn); + fileDisplayArea.appendChild(fileDiv); - // Fetch page count asynchronously - readFileAsArrayBuffer(file).then(buffer => { - return getPDFDocument(buffer).promise; - }).then(pdf => { - metaSpan.textContent = `${formatBytes(file.size)} • ${pdf.numPages} page${pdf.numPages !== 1 ? 's' : ''}`; - }).catch(e => { - console.warn('Error loading PDF page count:', e); - metaSpan.textContent = formatBytes(file.size); - }); + // Fetch page count asynchronously + readFileAsArrayBuffer(file) + .then((buffer) => { + return getPDFDocument(buffer).promise; + }) + .then((pdf) => { + metaSpan.textContent = `${formatBytes(file.size)} • ${pdf.numPages} page${pdf.numPages !== 1 ? 's' : ''}`; + }) + .catch((e) => { + console.warn('Error loading PDF page count:', e); + metaSpan.textContent = formatBytes(file.size); }); + }); - // Initialize icons immediately after synchronous render - createIcons({ icons }); - } else { - optionsPanel.classList.add('hidden'); - } + // Initialize icons immediately after synchronous render + createIcons({ icons }); + } else { + optionsPanel.classList.add('hidden'); + } }; const resetState = () => { - files = []; - const fileInput = document.getElementById('file-input') as HTMLInputElement; - if (fileInput) fileInput.value = ''; - updateUI(); + files = []; + const fileInput = document.getElementById('file-input') as HTMLInputElement; + if (fileInput) fileInput.value = ''; + updateUI(); }; async function convert() { - if (files.length === 0) { - showAlert('No File', 'Please upload a PDF file first.'); - return; - } - showLoader('Converting to TIFF...'); - try { - const pdf = await getPDFDocument( - await readFileAsArrayBuffer(files[0]) - ).promise; - const zip = new JSZip(); + if (files.length === 0) { + showAlert('No File', 'Please upload a PDF file first.'); + return; + } + showLoader('Converting to TIFF...'); + try { + const pdf = await getPDFDocument(await readFileAsArrayBuffer(files[0])) + .promise; - for (let i = 1; i <= pdf.numPages; i++) { - const page = await pdf.getPage(i); - const viewport = page.getViewport({ scale: 2.0 }); - const canvas = document.createElement('canvas'); - const context = canvas.getContext('2d'); - canvas.height = viewport.height; - canvas.width = viewport.width; - - await page.render({ canvasContext: context!, viewport: viewport, canvas }).promise; - - const imageData = context!.getImageData(0, 0, canvas.width, canvas.height); - const rgba = imageData.data; - - try { - const tiffData = UTIF.encodeImage(new Uint8Array(rgba), canvas.width, canvas.height); - const tiffBlob = new Blob([tiffData], { type: 'image/tiff' }); - zip.file(`page_${i}.tiff`, tiffBlob); - } catch (encodeError: any) { - console.warn(`TIFF encoding failed for page ${i}, using PNG fallback:`, encodeError); - // Fallback to PNG if TIFF encoding fails (e.g., PackBits compression issues) - const pngBlob = await new Promise((resolve) => - canvas.toBlob(resolve, 'image/png') - ); - if (pngBlob) { - zip.file(`page_${i}.png`, pngBlob); - } - } + if (pdf.numPages === 1) { + const page = await pdf.getPage(1); + const blob = await renderPage(page, 1); + downloadFile( + blob.blobData, + getCleanPdfFilename(files[0].name) + '.' + blob.ending + ); + } else { + const zip = new JSZip(); + for (let i = 1; i <= pdf.numPages; i++) { + const page = await pdf.getPage(i); + const blob = await renderPage(page, i); + if (blob.blobData) { + zip.file(`page_${i}.` + blob.ending, blob.blobData); } + } - const zipBlob = await zip.generateAsync({ type: 'blob' }); - downloadFile(zipBlob, 'converted_images.zip'); - showAlert('Success', 'PDF converted to TIFFs successfully!', 'success', () => { - resetState(); - }); - } catch (e) { - console.error(e); - showAlert( - 'Error', - 'Failed to convert PDF to TIFF. The file might be corrupted.' - ); - } finally { - hideLoader(); + const zipBlob = await zip.generateAsync({ type: 'blob' }); + downloadFile(zipBlob, getCleanPdfFilename(files[0].name) + '_tiffs.zip'); } + + showAlert( + 'Success', + 'PDF converted to TIFFs successfully!', + 'success', + () => { + resetState(); + } + ); + } catch (e) { + console.error(e); + showAlert( + 'Error', + 'Failed to convert PDF to TIFF. The file might be corrupted.' + ); + } finally { + hideLoader(); + } +} + +async function renderPage( + page: PDFPageProxy, + pageNumber: number +): Promise<{ blobData: Blob | null; ending: string }> { + const viewport = page.getViewport({ scale: 2.0 }); + const canvas = document.createElement('canvas'); + const context = canvas.getContext('2d'); + canvas.height = viewport.height; + canvas.width = viewport.width; + + await page.render({ + canvasContext: context!, + viewport: viewport, + canvas, + }).promise; + + const imageData = context!.getImageData(0, 0, canvas.width, canvas.height); + const rgba = imageData.data; + + try { + const tiffData = UTIF.encodeImage( + new Uint8Array(rgba), + canvas.width, + canvas.height + ); + const tiffBlob = new Blob([tiffData], { type: 'image/tiff' }); + return { + blobData: tiffBlob, + ending: 'tiff', + }; + } catch (encodeError: any) { + console.warn( + `TIFF encoding failed for page ${pageNumber}, using PNG fallback:`, + encodeError + ); + // Fallback to PNG if TIFF encoding fails (e.g., PackBits compression issues) + const pngBlob = await new Promise((resolve) => + canvas.toBlob(resolve, 'image/png') + ); + if (pngBlob) { + return { + blobData: pngBlob, + ending: 'png', + }; + } + } + + return { + blobData: null, + ending: 'tiff', + }; } document.addEventListener('DOMContentLoaded', () => { - const fileInput = document.getElementById('file-input') as HTMLInputElement; - const dropZone = document.getElementById('drop-zone'); - const processBtn = document.getElementById('process-btn'); - const backBtn = document.getElementById('back-to-tools'); + const fileInput = document.getElementById('file-input') as HTMLInputElement; + const dropZone = document.getElementById('drop-zone'); + const processBtn = document.getElementById('process-btn'); + const backBtn = document.getElementById('back-to-tools'); - if (backBtn) { - backBtn.addEventListener('click', () => { - window.location.href = import.meta.env.BASE_URL; - }); + if (backBtn) { + backBtn.addEventListener('click', () => { + window.location.href = import.meta.env.BASE_URL; + }); + } + + const handleFileSelect = (newFiles: FileList | null) => { + if (!newFiles || newFiles.length === 0) return; + const validFiles = Array.from(newFiles).filter( + (file) => file.type === 'application/pdf' + ); + + if (validFiles.length === 0) { + showAlert('Invalid File', 'Please upload a PDF file.'); + return; } - const handleFileSelect = (newFiles: FileList | null) => { - if (!newFiles || newFiles.length === 0) return; - const validFiles = Array.from(newFiles).filter( - (file) => file.type === 'application/pdf' - ); + files = [validFiles[0]]; + updateUI(); + }; - if (validFiles.length === 0) { - showAlert('Invalid File', 'Please upload a PDF file.'); - return; - } + if (fileInput && dropZone) { + fileInput.addEventListener('change', (e) => { + handleFileSelect((e.target as HTMLInputElement).files); + }); - files = [validFiles[0]]; - updateUI(); - }; + dropZone.addEventListener('dragover', (e) => { + e.preventDefault(); + dropZone.classList.add('bg-gray-700'); + }); - if (fileInput && dropZone) { - fileInput.addEventListener('change', (e) => { - handleFileSelect((e.target as HTMLInputElement).files); - }); + dropZone.addEventListener('dragleave', (e) => { + e.preventDefault(); + dropZone.classList.remove('bg-gray-700'); + }); - dropZone.addEventListener('dragover', (e) => { - e.preventDefault(); - dropZone.classList.add('bg-gray-700'); - }); + dropZone.addEventListener('drop', (e) => { + e.preventDefault(); + dropZone.classList.remove('bg-gray-700'); + handleFileSelect(e.dataTransfer?.files ?? null); + }); - dropZone.addEventListener('dragleave', (e) => { - e.preventDefault(); - dropZone.classList.remove('bg-gray-700'); - }); + fileInput.addEventListener('click', () => { + fileInput.value = ''; + }); + } - dropZone.addEventListener('drop', (e) => { - e.preventDefault(); - dropZone.classList.remove('bg-gray-700'); - handleFileSelect(e.dataTransfer?.files ?? null); - }); - - fileInput.addEventListener('click', () => { - fileInput.value = ''; - }); - } - - if (processBtn) { - processBtn.addEventListener('click', convert); - } + if (processBtn) { + processBtn.addEventListener('click', convert); + } }); diff --git a/src/js/logic/pdf-to-webp-page.ts b/src/js/logic/pdf-to-webp-page.ts index cdafdf7..c2c0fec 100644 --- a/src/js/logic/pdf-to-webp-page.ts +++ b/src/js/logic/pdf-to-webp-page.ts @@ -1,193 +1,237 @@ import { showLoader, hideLoader, showAlert } from '../ui.js'; -import { downloadFile, formatBytes, readFileAsArrayBuffer, getPDFDocument } from '../utils/helpers.js'; +import { + downloadFile, + formatBytes, + readFileAsArrayBuffer, + getPDFDocument, + getCleanPdfFilename, +} from '../utils/helpers.js'; import { createIcons, icons } from 'lucide'; import JSZip from 'jszip'; import * as pdfjsLib from 'pdfjs-dist'; +import { PDFPageProxy } from 'pdfjs-dist'; -pdfjsLib.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).toString(); +pdfjsLib.GlobalWorkerOptions.workerSrc = new URL( + 'pdfjs-dist/build/pdf.worker.min.mjs', + import.meta.url +).toString(); let files: File[] = []; const updateUI = () => { - const fileDisplayArea = document.getElementById('file-display-area'); - const optionsPanel = document.getElementById('options-panel'); - const dropZone = document.getElementById('drop-zone'); + const fileDisplayArea = document.getElementById('file-display-area'); + const optionsPanel = document.getElementById('options-panel'); + const dropZone = document.getElementById('drop-zone'); - if (!fileDisplayArea || !optionsPanel || !dropZone) return; + if (!fileDisplayArea || !optionsPanel || !dropZone) return; - fileDisplayArea.innerHTML = ''; + fileDisplayArea.innerHTML = ''; - if (files.length > 0) { - optionsPanel.classList.remove('hidden'); + if (files.length > 0) { + optionsPanel.classList.remove('hidden'); - files.forEach((file) => { - const fileDiv = document.createElement('div'); - fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm'; + files.forEach((file) => { + const fileDiv = document.createElement('div'); + fileDiv.className = + 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm'; - const infoContainer = document.createElement('div'); - infoContainer.className = 'flex flex-col overflow-hidden'; + const infoContainer = document.createElement('div'); + infoContainer.className = 'flex flex-col overflow-hidden'; - const nameSpan = document.createElement('div'); - nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1'; - nameSpan.textContent = file.name; + const nameSpan = document.createElement('div'); + nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1'; + nameSpan.textContent = file.name; - const metaSpan = document.createElement('div'); - metaSpan.className = 'text-xs text-gray-400'; - metaSpan.textContent = `${formatBytes(file.size)} • Loading pages...`; // Initial state + const metaSpan = document.createElement('div'); + metaSpan.className = 'text-xs text-gray-400'; + metaSpan.textContent = `${formatBytes(file.size)} • Loading pages...`; // Initial state - infoContainer.append(nameSpan, metaSpan); + infoContainer.append(nameSpan, metaSpan); - const removeBtn = document.createElement('button'); - removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0'; - removeBtn.innerHTML = ''; - removeBtn.onclick = () => { - files = []; - updateUI(); - }; + const removeBtn = document.createElement('button'); + removeBtn.className = + 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0'; + removeBtn.innerHTML = ''; + removeBtn.onclick = () => { + files = []; + updateUI(); + }; - fileDiv.append(infoContainer, removeBtn); - fileDisplayArea.appendChild(fileDiv); + fileDiv.append(infoContainer, removeBtn); + fileDisplayArea.appendChild(fileDiv); - // Fetch page count asynchronously - readFileAsArrayBuffer(file).then(buffer => { - return getPDFDocument(buffer).promise; - }).then(pdf => { - metaSpan.textContent = `${formatBytes(file.size)} • ${pdf.numPages} page${pdf.numPages !== 1 ? 's' : ''}`; - }).catch(e => { - console.warn('Error loading PDF page count:', e); - metaSpan.textContent = formatBytes(file.size); - }); + // Fetch page count asynchronously + readFileAsArrayBuffer(file) + .then((buffer) => { + return getPDFDocument(buffer).promise; + }) + .then((pdf) => { + metaSpan.textContent = `${formatBytes(file.size)} • ${pdf.numPages} page${pdf.numPages !== 1 ? 's' : ''}`; + }) + .catch((e) => { + console.warn('Error loading PDF page count:', e); + metaSpan.textContent = formatBytes(file.size); }); + }); - // Initialize icons immediately after synchronous render - createIcons({ icons }); - } else { - optionsPanel.classList.add('hidden'); - } + // Initialize icons immediately after synchronous render + createIcons({ icons }); + } else { + optionsPanel.classList.add('hidden'); + } }; const resetState = () => { - files = []; - const fileInput = document.getElementById('file-input') as HTMLInputElement; - if (fileInput) fileInput.value = ''; - const qualitySlider = document.getElementById('webp-quality') as HTMLInputElement; - const qualityValue = document.getElementById('webp-quality-value'); - if (qualitySlider) qualitySlider.value = '0.85'; - if (qualityValue) qualityValue.textContent = '85%'; - updateUI(); + files = []; + const fileInput = document.getElementById('file-input') as HTMLInputElement; + if (fileInput) fileInput.value = ''; + const qualitySlider = document.getElementById( + 'webp-quality' + ) as HTMLInputElement; + const qualityValue = document.getElementById('webp-quality-value'); + if (qualitySlider) qualitySlider.value = '0.85'; + if (qualityValue) qualityValue.textContent = '85%'; + updateUI(); }; async function convert() { - if (files.length === 0) { - showAlert('No File', 'Please upload a PDF file first.'); - return; - } - showLoader('Converting to WebP...'); - try { - const pdf = await getPDFDocument( - await readFileAsArrayBuffer(files[0]) - ).promise; - const zip = new JSZip(); + if (files.length === 0) { + showAlert('No File', 'Please upload a PDF file first.'); + return; + } + showLoader('Converting to WebP...'); + try { + const pdf = await getPDFDocument(await readFileAsArrayBuffer(files[0])) + .promise; - const qualityInput = document.getElementById('webp-quality') as HTMLInputElement; - const quality = qualityInput ? parseFloat(qualityInput.value) : 0.85; + const qualityInput = document.getElementById( + 'webp-quality' + ) as HTMLInputElement; + const quality = qualityInput ? parseFloat(qualityInput.value) : 0.85; - for (let i = 1; i <= pdf.numPages; i++) { - const page = await pdf.getPage(i); - const viewport = page.getViewport({ scale: 2.0 }); - const canvas = document.createElement('canvas'); - const context = canvas.getContext('2d'); - canvas.height = viewport.height; - canvas.width = viewport.width; - - await page.render({ canvasContext: context!, viewport: viewport, canvas }).promise; - - const blob = await new Promise((resolve) => - canvas.toBlob(resolve, 'image/webp', quality) - ); - if (blob) { - zip.file(`page_${i}.webp`, blob); - } + if (pdf.numPages === 1) { + const page = await pdf.getPage(1); + const blob = await renderPage(page, quality); + downloadFile(blob, getCleanPdfFilename(files[0].name) + '.webp'); + } else { + const zip = new JSZip(); + for (let i = 1; i <= pdf.numPages; i++) { + const page = await pdf.getPage(i); + const blob = await renderPage(page, quality); + if (blob) { + zip.file(`page_${i}.webp`, blob); } + } - const zipBlob = await zip.generateAsync({ type: 'blob' }); - downloadFile(zipBlob, 'converted_images.zip'); - showAlert('Success', 'PDF converted to WebPs successfully!', 'success', () => { - resetState(); - }); - } catch (e) { - console.error(e); - showAlert( - 'Error', - 'Failed to convert PDF to WebP. The file might be corrupted.' - ); - } finally { - hideLoader(); + const zipBlob = await zip.generateAsync({ type: 'blob' }); + downloadFile(zipBlob, getCleanPdfFilename(files[0].name) + '_webps.zip'); } + + showAlert( + 'Success', + 'PDF converted to WebPs successfully!', + 'success', + () => { + resetState(); + } + ); + } catch (e) { + console.error(e); + showAlert( + 'Error', + 'Failed to convert PDF to WebP. The file might be corrupted.' + ); + } finally { + hideLoader(); + } +} + +async function renderPage( + page: PDFPageProxy, + quality: number +): Promise { + const viewport = page.getViewport({ scale: 2.0 }); + const canvas = document.createElement('canvas'); + const context = canvas.getContext('2d'); + canvas.height = viewport.height; + canvas.width = viewport.width; + + await page.render({ + canvasContext: context!, + viewport: viewport, + canvas, + }).promise; + + const blob = await new Promise((resolve) => + canvas.toBlob(resolve, 'image/webp', quality) + ); + return blob; } document.addEventListener('DOMContentLoaded', () => { - const fileInput = document.getElementById('file-input') as HTMLInputElement; - const dropZone = document.getElementById('drop-zone'); - const processBtn = document.getElementById('process-btn'); - const backBtn = document.getElementById('back-to-tools'); - const qualitySlider = document.getElementById('webp-quality') as HTMLInputElement; - const qualityValue = document.getElementById('webp-quality-value'); + const fileInput = document.getElementById('file-input') as HTMLInputElement; + const dropZone = document.getElementById('drop-zone'); + const processBtn = document.getElementById('process-btn'); + const backBtn = document.getElementById('back-to-tools'); + const qualitySlider = document.getElementById( + 'webp-quality' + ) as HTMLInputElement; + const qualityValue = document.getElementById('webp-quality-value'); - if (backBtn) { - backBtn.addEventListener('click', () => { - window.location.href = import.meta.env.BASE_URL; - }); + if (backBtn) { + backBtn.addEventListener('click', () => { + window.location.href = import.meta.env.BASE_URL; + }); + } + + if (qualitySlider && qualityValue) { + qualitySlider.addEventListener('input', () => { + qualityValue.textContent = `${Math.round(parseFloat(qualitySlider.value) * 100)}%`; + }); + } + + const handleFileSelect = (newFiles: FileList | null) => { + if (!newFiles || newFiles.length === 0) return; + const validFiles = Array.from(newFiles).filter( + (file) => file.type === 'application/pdf' + ); + + if (validFiles.length === 0) { + showAlert('Invalid File', 'Please upload a PDF file.'); + return; } - if (qualitySlider && qualityValue) { - qualitySlider.addEventListener('input', () => { - qualityValue.textContent = `${Math.round(parseFloat(qualitySlider.value) * 100)}%`; - }); - } + files = [validFiles[0]]; + updateUI(); + }; - const handleFileSelect = (newFiles: FileList | null) => { - if (!newFiles || newFiles.length === 0) return; - const validFiles = Array.from(newFiles).filter( - (file) => file.type === 'application/pdf' - ); + if (fileInput && dropZone) { + fileInput.addEventListener('change', (e) => { + handleFileSelect((e.target as HTMLInputElement).files); + }); - if (validFiles.length === 0) { - showAlert('Invalid File', 'Please upload a PDF file.'); - return; - } + dropZone.addEventListener('dragover', (e) => { + e.preventDefault(); + dropZone.classList.add('bg-gray-700'); + }); - files = [validFiles[0]]; - updateUI(); - }; + dropZone.addEventListener('dragleave', (e) => { + e.preventDefault(); + dropZone.classList.remove('bg-gray-700'); + }); - if (fileInput && dropZone) { - fileInput.addEventListener('change', (e) => { - handleFileSelect((e.target as HTMLInputElement).files); - }); + dropZone.addEventListener('drop', (e) => { + e.preventDefault(); + dropZone.classList.remove('bg-gray-700'); + handleFileSelect(e.dataTransfer?.files ?? null); + }); - dropZone.addEventListener('dragover', (e) => { - e.preventDefault(); - dropZone.classList.add('bg-gray-700'); - }); + fileInput.addEventListener('click', () => { + fileInput.value = ''; + }); + } - dropZone.addEventListener('dragleave', (e) => { - e.preventDefault(); - dropZone.classList.remove('bg-gray-700'); - }); - - dropZone.addEventListener('drop', (e) => { - e.preventDefault(); - dropZone.classList.remove('bg-gray-700'); - handleFileSelect(e.dataTransfer?.files ?? null); - }); - - fileInput.addEventListener('click', () => { - fileInput.value = ''; - }); - } - - if (processBtn) { - processBtn.addEventListener('click', convert); - } + if (processBtn) { + processBtn.addEventListener('click', convert); + } }); diff --git a/src/js/logic/pdf-workflow-page.ts b/src/js/logic/pdf-workflow-page.ts new file mode 100644 index 0000000..7bac637 --- /dev/null +++ b/src/js/logic/pdf-workflow-page.ts @@ -0,0 +1,1578 @@ +import { showAlert } from '../ui.js'; +import { createWorkflowEditor, updateNodeDisplay } from '../workflow/editor'; +import { executeWorkflow } from '../workflow/engine'; +import { getAvailableTesseractLanguageEntries } from '../utils/tesseract-language-availability.js'; +import { + nodeRegistry, + getNodesByCategory, + createNodeByType, +} from '../workflow/nodes/registry'; +import type { BaseWorkflowNode } from '../workflow/nodes/base-node'; +import type { WorkflowEditor } from '../workflow/editor'; +import { + PDFInputNode, + EncryptedPDFError, +} from '../workflow/nodes/pdf-input-node'; +import { ImageInputNode } from '../workflow/nodes/image-input-node'; +import { WordToPdfNode } from '../workflow/nodes/word-to-pdf-node'; +import { ExcelToPdfNode } from '../workflow/nodes/excel-to-pdf-node'; +import { PowerPointToPdfNode } from '../workflow/nodes/powerpoint-to-pdf-node'; +import { TextToPdfNode } from '../workflow/nodes/text-to-pdf-node'; +import { SvgToPdfNode } from '../workflow/nodes/svg-to-pdf-node'; +import { EpubToPdfNode } from '../workflow/nodes/epub-to-pdf-node'; +import { EmailToPdfNode } from '../workflow/nodes/email-to-pdf-node'; +import { DigitalSignNode } from '../workflow/nodes/digital-sign-node'; +import { XpsToPdfNode } from '../workflow/nodes/xps-to-pdf-node'; +import { MobiToPdfNode } from '../workflow/nodes/mobi-to-pdf-node'; +import { Fb2ToPdfNode } from '../workflow/nodes/fb2-to-pdf-node'; +import { CbzToPdfNode } from '../workflow/nodes/cbz-to-pdf-node'; +import { MarkdownToPdfNode } from '../workflow/nodes/markdown-to-pdf-node'; +import { JsonToPdfNode } from '../workflow/nodes/json-to-pdf-node'; +import { XmlToPdfNode } from '../workflow/nodes/xml-to-pdf-node'; +import { WpdToPdfNode } from '../workflow/nodes/wpd-to-pdf-node'; +import { WpsToPdfNode } from '../workflow/nodes/wps-to-pdf-node'; +import { PagesToPdfNode } from '../workflow/nodes/pages-to-pdf-node'; +import { OdgToPdfNode } from '../workflow/nodes/odg-to-pdf-node'; +import { PubToPdfNode } from '../workflow/nodes/pub-to-pdf-node'; +import { VsdToPdfNode } from '../workflow/nodes/vsd-to-pdf-node'; +import { + saveWorkflow, + loadWorkflow, + exportWorkflow, + importWorkflow, + getSavedTemplateNames, + templateNameExists, + deleteTemplate, +} from '../workflow/serialization'; + +let workflowEditor: WorkflowEditor | null = null; +let selectedNodeId: string | null = null; +let deleteNodeHandler: EventListener | null = null; + +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initializePage); +} else { + initializePage(); +} + +async function initializePage() { + const container = document.getElementById('rete-container'); + if (!container) return; + + workflowEditor = await createWorkflowEditor(container); + const { editor, area, engine } = workflowEditor; + + buildToolbox(); + + editor.addPipe((context) => { + if (context.type === 'nodecreated' || context.type === 'noderemoved') { + updateNodeCount(); + } + if ( + context.type === 'connectioncreated' || + context.type === 'connectionremoved' + ) { + const conn = context.data; + updateNodeDisplay(conn.source, editor, area); + updateNodeDisplay(conn.target, editor, area); + } + return context; + }); + + document.getElementById('run-btn')?.addEventListener('click', async () => { + const allNodes = editor.getNodes() as BaseWorkflowNode[]; + if (allNodes.length === 0) { + showAlert('Error', 'Add at least one node to run the workflow.'); + return; + } + const hasInput = allNodes.some((n) => n.category === 'Input'); + const hasOutput = allNodes.some((n) => n.category === 'Output'); + if (!hasInput || !hasOutput) { + showAlert( + 'Error', + 'Your workflow needs at least one input node and one output node to run.' + ); + return; + } + + const statusText = document.getElementById('status-text'); + const runBtn = document.getElementById('run-btn') as HTMLButtonElement; + runBtn.disabled = true; + runBtn.classList.add('opacity-50', 'pointer-events-none'); + + try { + await executeWorkflow(editor, engine, area, (progress) => { + const msg = progress.message || `Processing ${progress.nodeName}...`; + if (statusText) statusText.textContent = msg; + }); + if (statusText) statusText.textContent = 'Workflow completed'; + } catch (err) { + if (statusText) statusText.textContent = 'Error during execution'; + showAlert('Error', (err as Error).message); + } finally { + runBtn.disabled = false; + runBtn.classList.remove('opacity-50', 'pointer-events-none'); + } + }); + + document.getElementById('clear-btn')?.addEventListener('click', async () => { + await editor.clear(); + updateNodeCount(); + const statusText = document.getElementById('status-text'); + if (statusText) statusText.textContent = 'Ready'; + document.getElementById('settings-sidebar')?.classList.add('hidden'); + }); + + document.getElementById('close-settings')?.addEventListener('click', () => { + document.getElementById('settings-sidebar')?.classList.add('hidden'); + }); + + document.getElementById('save-btn')?.addEventListener('click', () => { + showSaveTemplateModal(editor, area); + }); + + document.getElementById('load-btn')?.addEventListener('click', () => { + showLoadTemplateModal(editor, area); + }); + + document.getElementById('export-btn')?.addEventListener('click', () => { + exportWorkflow(editor, area); + }); + + document.getElementById('import-btn')?.addEventListener('click', async () => { + await importWorkflow(editor, area); + updateNodeCount(); + }); + + // Mobile toolbox sidebar toggle + const toolboxSidebar = document.getElementById('toolbox-sidebar'); + const toolboxBackdrop = document.getElementById('toolbox-backdrop'); + + function closeToolbox() { + toolboxSidebar?.classList.add('hidden'); + toolboxSidebar?.classList.remove('flex'); + toolboxBackdrop?.classList.add('hidden'); + } + + function openToolbox() { + toolboxSidebar?.classList.remove('hidden'); + toolboxSidebar?.classList.add('flex'); + toolboxBackdrop?.classList.remove('hidden'); + } + + document.getElementById('toolbox-toggle')?.addEventListener('click', () => { + if (toolboxSidebar?.classList.contains('hidden')) { + openToolbox(); + } else { + closeToolbox(); + } + }); + + toolboxBackdrop?.addEventListener('click', closeToolbox); + + document.getElementById('node-search')?.addEventListener('input', (e) => { + const query = (e.target as HTMLInputElement).value.toLowerCase(); + const items = document.querySelectorAll('.toolbox-node-item'); + const categories = + document.querySelectorAll('.toolbox-category'); + + items.forEach((item) => { + const label = item.dataset.label?.toLowerCase() ?? ''; + item.style.display = label.includes(query) ? '' : 'none'; + }); + + categories.forEach((cat) => { + const itemsContainer = cat.querySelector('.toolbox-items'); + if (itemsContainer) itemsContainer.style.display = ''; + const visibleItems = cat.querySelectorAll( + '.toolbox-node-item:not([style*="display: none"])' + ); + cat.style.display = visibleItems.length > 0 ? '' : 'none'; + }); + }); + + let justPicked = false; + let dragDistance = 0; + let pickedNodeId: string | null = null; + + area.addPipe((context) => { + if (context.type === 'nodepicked') { + const nodeId = context.data.id; + selectedNodeId = nodeId; + justPicked = true; + pickedNodeId = nodeId; + dragDistance = 0; + } + if (context.type === 'nodetranslated') { + const dx = context.data.position.x - context.data.previous.x; + const dy = context.data.position.y - context.data.previous.y; + dragDistance += Math.abs(dx) + Math.abs(dy); + } + if (context.type === 'nodedragged') { + if (pickedNodeId && dragDistance < 5) { + const node = editor.getNode(pickedNodeId) as BaseWorkflowNode; + if (node) { + showNodeSettings(node); + } + } + pickedNodeId = null; + } + if (context.type === 'translated') { + container.classList.add('is-panning'); + } + return context; + }); + + container.addEventListener('mouseup', () => + container.classList.remove('is-panning') + ); + container.addEventListener('mouseleave', () => + container.classList.remove('is-panning') + ); + + container.addEventListener('click', (e) => { + if (justPicked) { + justPicked = false; + return; + } + if ((e.target as HTMLElement).closest('[data-testid="node"]')) return; + selectedNodeId = null; + document.getElementById('settings-sidebar')?.classList.add('hidden'); + }); + + document.addEventListener('keydown', (e) => { + if (!selectedNodeId || !workflowEditor) return; + if (e.key === 'Delete' || e.key === 'Backspace') { + const tag = (e.target as HTMLElement).tagName; + if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return; + e.preventDefault(); + deleteSelectedNode(); + } + }); + + if (deleteNodeHandler) { + document.removeEventListener('wf-delete-node', deleteNodeHandler); + } + deleteNodeHandler = ((e: CustomEvent) => { + const nodeId = e.detail?.nodeId; + if (nodeId) deleteNodeById(nodeId); + }) as EventListener; + document.addEventListener('wf-delete-node', deleteNodeHandler); +} + +async function deleteNodeById(nodeId: string) { + if (!workflowEditor) return; + const { editor } = workflowEditor; + + const conns = editor + .getConnections() + .filter((c) => c.source === nodeId || c.target === nodeId); + for (const conn of conns) { + await editor.removeConnection(conn.id); + } + await editor.removeNode(nodeId); + + if (selectedNodeId === nodeId) { + selectedNodeId = null; + document.getElementById('settings-sidebar')?.classList.add('hidden'); + } + updateNodeCount(); +} + +async function deleteSelectedNode() { + if (!selectedNodeId) return; + await deleteNodeById(selectedNodeId); +} + +function updateNodeCount() { + if (!workflowEditor) return; + const count = workflowEditor.editor.getNodes().length; + const el = document.getElementById('node-count'); + if (el) el.textContent = `${count} node${count !== 1 ? 's' : ''}`; +} + +function showSaveTemplateModal( + editor: WorkflowEditor['editor'], + area: WorkflowEditor['area'] +) { + const modal = document.getElementById('save-template-modal')!; + const nameInput = document.getElementById( + 'save-template-name' + ) as HTMLInputElement; + const errorEl = document.getElementById('save-template-error')!; + const confirmBtn = document.getElementById('save-template-confirm')!; + const cancelBtn = document.getElementById('save-template-cancel')!; + + nameInput.value = ''; + errorEl.classList.add('hidden'); + modal.classList.remove('hidden'); + nameInput.focus(); + + const cleanup = () => { + modal.classList.add('hidden'); + confirmBtn.replaceWith(confirmBtn.cloneNode(true)); + cancelBtn.replaceWith(cancelBtn.cloneNode(true)); + nameInput.removeEventListener('keydown', onKeydown); + }; + + const doSave = () => { + const name = nameInput.value.trim(); + if (!name) { + errorEl.textContent = 'Please enter a name.'; + errorEl.classList.remove('hidden'); + return; + } + if (templateNameExists(name)) { + errorEl.textContent = 'A template with this name already exists.'; + errorEl.classList.remove('hidden'); + return; + } + saveWorkflow(editor, area, name); + cleanup(); + showAlert('Saved', `Template "${name}" saved.`, 'success'); + }; + + const onKeydown = (e: KeyboardEvent) => { + if (e.key === 'Enter') doSave(); + if (e.key === 'Escape') cleanup(); + }; + + nameInput.addEventListener('keydown', onKeydown); + document + .getElementById('save-template-confirm')! + .addEventListener('click', doSave); + document + .getElementById('save-template-cancel')! + .addEventListener('click', cleanup); +} + +function showLoadTemplateModal( + editor: WorkflowEditor['editor'], + area: WorkflowEditor['area'] +) { + const modal = document.getElementById('load-template-modal')!; + const listEl = document.getElementById('load-template-list')!; + const emptyEl = document.getElementById('load-template-empty')!; + const cancelBtn = document.getElementById('load-template-cancel')!; + + const names = getSavedTemplateNames(); + listEl.innerHTML = ''; + + if (names.length === 0) { + emptyEl.classList.remove('hidden'); + } else { + emptyEl.classList.add('hidden'); + for (const name of names) { + const row = document.createElement('div'); + row.className = + 'group flex items-center gap-2 bg-gray-900/60 hover:bg-gray-700/50 rounded-lg px-3 py-2.5 border border-gray-700/50 transition-colors cursor-pointer'; + + const icon = document.createElement('i'); + icon.className = 'ph ph-file-text text-base text-gray-500 flex-shrink-0'; + row.appendChild(icon); + + const label = document.createElement('span'); + label.className = 'text-gray-200 text-sm truncate flex-1'; + label.textContent = name; + row.appendChild(label); + + const loadBtn = document.createElement('button'); + loadBtn.className = + 'bg-indigo-600 hover:bg-indigo-500 text-white text-xs font-medium px-3 py-1.5 rounded-md transition-colors flex-shrink-0'; + loadBtn.textContent = 'Load'; + loadBtn.addEventListener('click', async () => { + const loaded = await loadWorkflow(editor, area, name); + cleanup(); + if (loaded) { + updateNodeCount(); + showAlert('Loaded', `Template "${name}" loaded.`, 'success'); + } else { + showAlert('Error', 'Failed to load template.'); + } + }); + row.appendChild(loadBtn); + + const delBtn = document.createElement('button'); + delBtn.className = + 'text-gray-600 hover:text-red-400 transition-colors flex-shrink-0'; + delBtn.innerHTML = ''; + delBtn.addEventListener('click', () => { + deleteTemplate(name); + row.remove(); + const remaining = getSavedTemplateNames(); + if (remaining.length === 0) emptyEl.classList.remove('hidden'); + }); + row.appendChild(delBtn); + + listEl.appendChild(row); + } + } + + modal.classList.remove('hidden'); + + const cleanup = () => { + modal.classList.add('hidden'); + cancelBtn.replaceWith(cancelBtn.cloneNode(true)); + }; + + document + .getElementById('load-template-cancel')! + .addEventListener('click', cleanup); +} + +function buildToolbox() { + const container = document.getElementById('toolbox-categories'); + if (!container) return; + + const categorized = getNodesByCategory(); + const categoryOrder: Array<{ key: string; label: string; color: string }> = [ + { key: 'Input', label: 'Input', color: 'text-blue-400' }, + { + key: 'Edit & Annotate', + label: 'Edit & Annotate', + color: 'text-indigo-300', + }, + { + key: 'Organize & Manage', + label: 'Organize & Manage', + color: 'text-violet-300', + }, + { + key: 'Optimize & Repair', + label: 'Optimize & Repair', + color: 'text-amber-300', + }, + { key: 'Secure PDF', label: 'Secure PDF', color: 'text-rose-300' }, + { key: 'Output', label: 'Output', color: 'text-teal-300' }, + ]; + + for (const cat of categoryOrder) { + const entries = categorized[cat.key as keyof typeof categorized] ?? []; + if (entries.length === 0) continue; + + const section = document.createElement('div'); + section.className = 'toolbox-category'; + + const header = document.createElement('button'); + header.className = `w-full flex items-center justify-between text-xs font-bold uppercase tracking-wider ${cat.color} mb-1.5 px-1 hover:opacity-80 transition-opacity`; + header.type = 'button'; + + const headerLabel = document.createElement('span'); + headerLabel.textContent = cat.label; + header.appendChild(headerLabel); + + const chevronWrap = document.createElement('span'); + chevronWrap.className = 'flex-shrink-0'; + chevronWrap.innerHTML = ''; + header.appendChild(chevronWrap); + + const itemsContainer = document.createElement('div'); + itemsContainer.className = 'toolbox-items'; + + header.addEventListener('click', () => { + const collapsed = itemsContainer.style.display === 'none'; + itemsContainer.style.display = collapsed ? '' : 'none'; + const iconName = collapsed ? 'ph-caret-down' : 'ph-caret-up'; + chevronWrap.innerHTML = ``; + }); + + section.appendChild(header); + + for (const entry of entries) { + const item = document.createElement('button'); + item.className = + 'toolbox-node-item w-full text-left px-2 py-1.5 rounded-md text-gray-300 hover:bg-gray-700 hover:text-white transition-colors text-xs flex items-center gap-2'; + item.dataset.label = entry.label; + item.dataset.type = Object.keys(nodeRegistry).find( + (k) => nodeRegistry[k] === entry + )!; + + const iconEl = document.createElement('i'); + iconEl.className = `ph ${entry.icon} text-sm flex-shrink-0`; + item.appendChild(iconEl); + + const labelEl = document.createElement('span'); + labelEl.textContent = entry.label; + item.appendChild(labelEl); + + item.addEventListener('click', () => { + addNodeToCanvas(item.dataset.type!); + if (window.innerWidth < 768) { + document.getElementById('toolbox-sidebar')?.classList.add('hidden'); + document.getElementById('toolbox-sidebar')?.classList.remove('flex'); + document.getElementById('toolbox-backdrop')?.classList.add('hidden'); + } + }); + + item.draggable = true; + item.addEventListener('dragstart', (e) => { + e.dataTransfer?.setData( + 'application/rete-node-type', + item.dataset.type! + ); + e.dataTransfer!.effectAllowed = 'copy'; + }); + + itemsContainer.appendChild(item); + } + + section.appendChild(itemsContainer); + container.appendChild(section); + } + + const reteContainer = document.getElementById('rete-container'); + if (reteContainer) { + reteContainer.addEventListener('dragover', (e) => { + e.preventDefault(); + e.dataTransfer!.dropEffect = 'copy'; + }); + reteContainer.addEventListener('drop', (e) => { + e.preventDefault(); + const nodeType = e.dataTransfer?.getData('application/rete-node-type'); + if (!nodeType || !workflowEditor) return; + + const { area } = workflowEditor; + const rect = reteContainer.getBoundingClientRect(); + const { x: tx, y: ty, k } = area.area.transform; + const x = (e.clientX - rect.left - tx) / k; + const y = (e.clientY - rect.top - ty) / k; + addNodeToCanvas(nodeType, { x, y }); + }); + } +} + +async function addNodeToCanvas( + type: string, + position?: { x: number; y: number } +) { + if (!workflowEditor) return; + const { editor, area } = workflowEditor; + + try { + const node = createNodeByType(type); + if (!node) { + console.error('Node type not found in registry:', type); + return; + } + await editor.addNode(node); + + const pos = position || getCanvasCenter(area); + await area.translate(node.id, pos); + } catch (err) { + console.error('Failed to add node to canvas:', err); + } +} + +function getCanvasCenter(area: WorkflowEditor['area']): { + x: number; + y: number; +} { + const container = area.container; + const rect = container.getBoundingClientRect(); + const { x: tx, y: ty, k } = area.area.transform; + const cx = (rect.width / 2 - tx) / k; + const cy = (rect.height / 2 - ty) / k; + return { + x: cx + (Math.random() - 0.5) * 100, + y: cy + (Math.random() - 0.5) * 100, + }; +} + +function buildFileList( + container: HTMLElement, + filenames: string[], + onRemove: (index: number) => void +) { + const list = document.createElement('div'); + list.className = 'flex flex-col gap-1.5 mb-2'; + + filenames.forEach((name, i) => { + const row = document.createElement('div'); + row.className = + 'flex items-center justify-between bg-gray-900 rounded-lg px-3 py-2'; + + const nameEl = document.createElement('span'); + nameEl.className = 'text-sm text-white truncate flex-1 mr-2'; + nameEl.textContent = name; + row.appendChild(nameEl); + + const removeBtn = document.createElement('button'); + removeBtn.className = + 'text-gray-500 hover:text-red-400 text-lg leading-none flex-shrink-0'; + removeBtn.innerHTML = '×'; + removeBtn.addEventListener('click', () => onRemove(i)); + row.appendChild(removeBtn); + + list.appendChild(row); + }); + + container.appendChild(list); +} + +function promptPdfPassword(filename: string): Promise { + return new Promise((resolve) => { + const modal = document.getElementById('pdf-password-modal')!; + const filenameEl = document.getElementById('pdf-password-filename')!; + const input = document.getElementById( + 'pdf-password-input' + ) as HTMLInputElement; + const errorEl = document.getElementById('pdf-password-error')!; + const skipBtn = document.getElementById('pdf-password-skip')!; + const unlockBtn = document.getElementById('pdf-password-unlock')!; + + filenameEl.textContent = filename; + input.value = ''; + errorEl.classList.add('hidden'); + modal.classList.remove('hidden'); + input.focus(); + + const cleanup = () => { + modal.classList.add('hidden'); + skipBtn.replaceWith(skipBtn.cloneNode(true)); + unlockBtn.replaceWith(unlockBtn.cloneNode(true)); + input.removeEventListener('keydown', onKeydown); + }; + + const onKeydown = (e: KeyboardEvent) => { + if (e.key === 'Enter') { + cleanup(); + resolve(input.value || null); + } + if (e.key === 'Escape') { + cleanup(); + resolve(null); + } + }; + + input.addEventListener('keydown', onKeydown); + document + .getElementById('pdf-password-skip')! + .addEventListener('click', () => { + cleanup(); + resolve(null); + }); + document + .getElementById('pdf-password-unlock')! + .addEventListener('click', () => { + cleanup(); + resolve(input.value || null); + }); + }); +} + +function showNodeSettings(node: BaseWorkflowNode) { + const sidebar = document.getElementById('settings-sidebar'); + const title = document.getElementById('settings-title'); + const content = document.getElementById('settings-content'); + if (!sidebar || !title || !content) return; + + sidebar.classList.remove('hidden'); + title.textContent = node.label; + content.innerHTML = ''; + + if (node instanceof PDFInputNode) { + const fileSection = document.createElement('div'); + + const label = document.createElement('label'); + label.className = 'block text-xs text-gray-400 mb-1'; + label.textContent = 'PDF Files'; + fileSection.appendChild(label); + + if (node.hasFile()) { + buildFileList(fileSection, node.getFilenames(), (index) => { + node.removeFile(index); + showNodeSettings(node); + }); + } + + const uploadBtn = document.createElement('button'); + uploadBtn.className = + 'w-full bg-gray-700 hover:bg-gray-600 text-white text-xs px-3 py-2 rounded-lg transition-colors'; + uploadBtn.textContent = node.hasFile() ? 'Add More Files' : 'Upload PDFs'; + + const fileInput = document.createElement('input'); + fileInput.type = 'file'; + fileInput.accept = '.pdf'; + fileInput.multiple = true; + fileInput.className = 'hidden'; + fileInput.addEventListener('change', async (e) => { + const files = Array.from((e.target as HTMLInputElement).files ?? []); + if (files.length === 0) return; + for (const file of files) { + try { + await node.addFile(file); + } catch (err) { + if (err instanceof EncryptedPDFError) { + const password = await promptPdfPassword(file.name); + if (password) { + try { + await node.addDecryptedFile(file, password); + } catch { + showAlert( + 'Error', + `Wrong password or failed to decrypt "${file.name}".` + ); + } + } + } else { + showAlert('Error', 'Failed to load PDF: ' + (err as Error).message); + } + } + } + showNodeSettings(node); + }); + + uploadBtn.addEventListener('click', () => fileInput.click()); + fileSection.appendChild(uploadBtn); + fileSection.appendChild(fileInput); + content.appendChild(fileSection); + return; + } + + if (node instanceof ImageInputNode) { + const fileSection = document.createElement('div'); + + const label = document.createElement('label'); + label.className = 'block text-xs text-gray-400 mb-1'; + label.textContent = 'Images'; + fileSection.appendChild(label); + + const formatHint = document.createElement('p'); + formatHint.className = 'text-[10px] text-gray-500 mb-2'; + formatHint.textContent = + 'Supported: JPG, PNG, BMP, GIF, TIFF, WebP, HEIC, PSD, SVG, PNM, PGM, PBM, PPM, PAM, JXR, JPX, JP2'; + fileSection.appendChild(formatHint); + + if (node.hasFile()) { + buildFileList(fileSection, node.getFilenames(), (index) => { + node.removeFile(index); + showNodeSettings(node); + }); + } + + const uploadBtn = document.createElement('button'); + uploadBtn.className = + 'w-full bg-gray-700 hover:bg-gray-600 text-white text-xs px-3 py-2 rounded-lg transition-colors'; + uploadBtn.textContent = node.hasFile() + ? 'Add More Images' + : 'Upload Images'; + + const fileInput = document.createElement('input'); + fileInput.type = 'file'; + fileInput.accept = 'image/*'; + fileInput.multiple = true; + fileInput.className = 'hidden'; + fileInput.addEventListener('change', async (e) => { + const files = Array.from((e.target as HTMLInputElement).files ?? []); + if (files.length === 0) return; + try { + await node.addFiles(files); + showNodeSettings(node); + } catch (err) { + showAlert('Error', 'Failed to load images: ' + (err as Error).message); + } + }); + + uploadBtn.addEventListener('click', () => fileInput.click()); + fileSection.appendChild(uploadBtn); + fileSection.appendChild(fileInput); + content.appendChild(fileSection); + return; + } + + if (node instanceof DigitalSignNode) { + const certSection = document.createElement('div'); + + const certLabel = document.createElement('label'); + certLabel.className = 'block text-xs text-gray-400 mb-1'; + certLabel.textContent = 'Certificate (.pfx, .p12, .pem)'; + certSection.appendChild(certLabel); + + if (node.hasCertFile()) { + const certFileDiv = document.createElement('div'); + certFileDiv.className = + 'flex items-center justify-between bg-gray-700 px-3 py-2 rounded-lg mb-2'; + + const certName = document.createElement('span'); + certName.className = 'text-xs text-gray-200 truncate flex-1'; + certName.textContent = node.getCertFilename(); + + const statusDot = document.createElement('span'); + statusDot.className = `w-2 h-2 rounded-full flex-shrink-0 mx-2 ${node.hasCert() ? 'bg-green-400' : 'bg-yellow-400'}`; + + const removeBtn = document.createElement('button'); + removeBtn.className = + 'text-red-400 hover:text-red-300 text-xs flex-shrink-0'; + removeBtn.textContent = 'Remove'; + removeBtn.addEventListener('click', () => { + node.removeCert(); + showNodeSettings(node); + }); + + certFileDiv.append(certName, statusDot, removeBtn); + certSection.appendChild(certFileDiv); + + if (node.needsPassword()) { + const pwSection = document.createElement('div'); + pwSection.className = 'mb-2'; + + const pwLabel = document.createElement('label'); + pwLabel.className = 'block text-xs text-gray-400 mb-1'; + pwLabel.textContent = 'Certificate Password'; + pwSection.appendChild(pwLabel); + + const pwRow = document.createElement('div'); + pwRow.className = 'flex gap-2'; + + const pwInput = document.createElement('input'); + pwInput.type = 'password'; + pwInput.placeholder = 'Enter password...'; + pwInput.className = + 'flex-1 bg-gray-700 border border-gray-600 text-white text-xs px-3 py-2 rounded-lg focus:outline-none focus:border-indigo-500'; + + const unlockBtn = document.createElement('button'); + unlockBtn.className = + 'bg-indigo-600 hover:bg-indigo-500 text-white text-xs px-3 py-2 rounded-lg transition-colors flex-shrink-0'; + unlockBtn.textContent = 'Unlock'; + + const statusMsg = document.createElement('div'); + statusMsg.className = 'text-xs mt-1 hidden'; + + const doUnlock = async () => { + const pw = pwInput.value; + if (!pw) return; + unlockBtn.textContent = 'Unlocking...'; + unlockBtn.disabled = true; + const success = await node.unlockCert(pw); + if (success) { + showNodeSettings(node); + } else { + unlockBtn.textContent = 'Unlock'; + unlockBtn.disabled = false; + statusMsg.textContent = 'Incorrect password'; + statusMsg.className = 'text-xs mt-1 text-red-400'; + statusMsg.classList.remove('hidden'); + } + }; + + unlockBtn.addEventListener('click', doUnlock); + pwInput.addEventListener('keypress', (e) => { + if (e.key === 'Enter') doUnlock(); + }); + + pwRow.append(pwInput, unlockBtn); + pwSection.append(pwRow, statusMsg); + certSection.appendChild(pwSection); + } else if (node.hasCert()) { + const okMsg = document.createElement('div'); + okMsg.className = 'text-xs text-green-400 mb-2'; + okMsg.textContent = 'Certificate unlocked'; + certSection.appendChild(okMsg); + } + } + + const uploadBtn = document.createElement('button'); + uploadBtn.className = + 'w-full bg-gray-700 hover:bg-gray-600 text-white text-xs px-3 py-2 rounded-lg transition-colors'; + uploadBtn.textContent = node.hasCertFile() + ? 'Change Certificate' + : 'Upload Certificate'; + + const certInput = document.createElement('input'); + certInput.type = 'file'; + certInput.accept = '.pfx,.p12,.pem'; + certInput.className = 'hidden'; + certInput.addEventListener('change', (e) => { + const file = (e.target as HTMLInputElement).files?.[0]; + if (!file) return; + node.setCertFile(file); + + const isPem = file.name.toLowerCase().endsWith('.pem'); + if (isPem) { + file.text().then(async (pemContent) => { + const isEncrypted = pemContent.includes('ENCRYPTED'); + if (!isEncrypted) { + await node.unlockCert(''); + } + showNodeSettings(node); + }); + } else { + showNodeSettings(node); + } + }); + + uploadBtn.addEventListener('click', () => certInput.click()); + certSection.append(uploadBtn, certInput); + content.appendChild(certSection); + + const divider = document.createElement('div'); + divider.className = 'border-t border-gray-700 my-3'; + content.appendChild(divider); + } + + const fileInputConfigs: { + cls: any; + label: string; + accept: string; + btnLabel: string; + hint?: string; + }[] = [ + { + cls: WordToPdfNode, + label: 'Word Documents', + accept: '.doc,.docx,.odt,.rtf', + btnLabel: 'Documents', + hint: 'Supported: DOC, DOCX, ODT, RTF', + }, + { + cls: ExcelToPdfNode, + label: 'Spreadsheets', + accept: '.xlsx,.xls,.ods,.csv', + btnLabel: 'Spreadsheets', + hint: 'Supported: XLSX, XLS, ODS, CSV', + }, + { + cls: PowerPointToPdfNode, + label: 'Presentations', + accept: '.ppt,.pptx,.odp', + btnLabel: 'Presentations', + hint: 'Supported: PPT, PPTX, ODP', + }, + { + cls: TextToPdfNode, + label: 'Text Files', + accept: '.txt', + btnLabel: 'Text Files', + }, + { + cls: SvgToPdfNode, + label: 'SVG Files', + accept: '.svg', + btnLabel: 'SVG Files', + }, + { + cls: EpubToPdfNode, + label: 'EPUB Files', + accept: '.epub', + btnLabel: 'EPUB Files', + }, + { + cls: EmailToPdfNode, + label: 'Email Files', + accept: '.eml,.msg', + btnLabel: 'Email Files', + hint: 'Supported: EML, MSG', + }, + { + cls: XpsToPdfNode, + label: 'XPS Files', + accept: '.xps,.oxps', + btnLabel: 'XPS Files', + hint: 'Supported: XPS, OXPS', + }, + { + cls: MobiToPdfNode, + label: 'MOBI Files', + accept: '.mobi', + btnLabel: 'MOBI Files', + }, + { + cls: Fb2ToPdfNode, + label: 'FB2 Files', + accept: '.fb2', + btnLabel: 'FB2 Files', + }, + { + cls: CbzToPdfNode, + label: 'Comic Archives', + accept: '.cbz,.cbr', + btnLabel: 'Comics', + hint: 'Supported: CBZ, CBR', + }, + { + cls: MarkdownToPdfNode, + label: 'Markdown Files', + accept: '.md,.markdown', + btnLabel: 'Markdown Files', + }, + { + cls: JsonToPdfNode, + label: 'JSON Files', + accept: '.json', + btnLabel: 'JSON Files', + }, + { + cls: XmlToPdfNode, + label: 'XML Files', + accept: '.xml', + btnLabel: 'XML Files', + }, + { + cls: WpdToPdfNode, + label: 'WordPerfect Files', + accept: '.wpd', + btnLabel: 'WPD Files', + }, + { + cls: WpsToPdfNode, + label: 'WPS Files', + accept: '.wps', + btnLabel: 'WPS Files', + }, + { + cls: PagesToPdfNode, + label: 'Pages Files', + accept: '.pages', + btnLabel: 'Pages Files', + }, + { + cls: OdgToPdfNode, + label: 'ODG Files', + accept: '.odg', + btnLabel: 'ODG Files', + }, + { + cls: PubToPdfNode, + label: 'Publisher Files', + accept: '.pub', + btnLabel: 'PUB Files', + }, + { + cls: VsdToPdfNode, + label: 'Visio Files', + accept: '.vsd,.vsdx', + btnLabel: 'Visio Files', + hint: 'Supported: VSD, VSDX', + }, + ]; + + const fileInputConfig = fileInputConfigs.find((c) => node instanceof c.cls); + if (fileInputConfig) { + const fileNode = node as InstanceType; + const fileSection = document.createElement('div'); + + const label = document.createElement('label'); + label.className = 'block text-xs text-gray-400 mb-1'; + label.textContent = fileInputConfig.label; + fileSection.appendChild(label); + + if (fileInputConfig.hint) { + const hint = document.createElement('p'); + hint.className = 'text-[10px] text-gray-500 mb-2'; + hint.textContent = fileInputConfig.hint; + fileSection.appendChild(hint); + } + + if (fileNode.hasFile()) { + buildFileList(fileSection, fileNode.getFilenames(), (index) => { + fileNode.removeFile(index); + showNodeSettings(node); + }); + } + + const uploadBtn = document.createElement('button'); + uploadBtn.className = + 'w-full bg-gray-700 hover:bg-gray-600 text-white text-xs px-3 py-2 rounded-lg transition-colors'; + uploadBtn.textContent = fileNode.hasFile() + ? `Add More ${fileInputConfig.btnLabel}` + : `Upload ${fileInputConfig.btnLabel}`; + + const fileInput = document.createElement('input'); + fileInput.type = 'file'; + fileInput.accept = fileInputConfig.accept; + fileInput.multiple = true; + fileInput.className = 'hidden'; + fileInput.addEventListener('change', async (e) => { + const files = Array.from((e.target as HTMLInputElement).files ?? []); + if (files.length === 0) return; + try { + await fileNode.addFiles(files); + showNodeSettings(node); + } catch (err) { + showAlert('Error', `Failed to load files: ${(err as Error).message}`); + } + }); + + uploadBtn.addEventListener('click', () => fileInput.click()); + fileSection.appendChild(uploadBtn); + fileSection.appendChild(fileInput); + content.appendChild(fileSection); + + const controlEntries = Object.entries(node.controls); + if (controlEntries.length > 0) { + const divider = document.createElement('div'); + divider.className = 'border-t border-gray-700 my-3'; + content.appendChild(divider); + } else { + return; + } + } + + const controlEntries = Object.entries(node.controls); + if (controlEntries.length === 0) { + const empty = document.createElement('p'); + empty.className = 'text-xs text-gray-500'; + empty.textContent = 'No configurable settings for this node.'; + content.appendChild(empty); + return; + } + + const dropdownOptions: Record = { + format: [ + { label: 'JPG', value: 'jpg' }, + { label: 'PNG', value: 'png' }, + { label: 'WebP', value: 'webp' }, + { label: 'SVG', value: 'svg' }, + ], + position: [ + { label: 'Bottom Center', value: 'bottom-center' }, + { label: 'Bottom Left', value: 'bottom-left' }, + { label: 'Bottom Right', value: 'bottom-right' }, + { label: 'Top Center', value: 'top-center' }, + { label: 'Top Left', value: 'top-left' }, + { label: 'Top Right', value: 'top-right' }, + ], + orientation: [ + { label: 'Auto (Keep Original)', value: 'auto' }, + { label: 'Portrait', value: 'portrait' }, + { label: 'Landscape', value: 'landscape' }, + ], + direction: [ + { label: 'Vertical', value: 'vertical' }, + { label: 'Horizontal', value: 'horizontal' }, + ], + pagesPerSheet: [ + { label: '2', value: '2' }, + { label: '4', value: '4' }, + { label: '9', value: '9' }, + { label: '16', value: '16' }, + ], + fontFamily: [ + { label: 'Helvetica', value: 'helv' }, + { label: 'Times Roman', value: 'times' }, + { label: 'Courier', value: 'cour' }, + { label: 'Times Italic', value: 'tiro' }, + ], + pageSize: [ + { label: 'A4', value: 'a4' }, + { label: 'Letter', value: 'letter' }, + { label: 'Legal', value: 'legal' }, + ], + targetSize: [ + { label: 'A4', value: 'A4' }, + { label: 'Letter', value: 'Letter' }, + { label: 'Legal', value: 'Legal' }, + { label: 'A3', value: 'A3' }, + { label: 'A5', value: 'A5' }, + { label: 'Tabloid', value: 'Tabloid' }, + { label: 'Custom', value: 'Custom' }, + ], + scalingMode: [ + { label: 'Fit (keep full page visible)', value: 'fit' }, + { label: 'Fill (cover full target page)', value: 'fill' }, + ], + customUnits: [ + { label: 'Millimeters (mm)', value: 'mm' }, + { label: 'Inches (in)', value: 'in' }, + ], + numberFormat: [ + { label: 'Simple (1, 2, 3)', value: 'simple' }, + { label: 'Page X of Y', value: 'page_x_of_y' }, + ], + angle: [ + { label: '90° Clockwise', value: '90' }, + { label: '180°', value: '180' }, + { label: '90° Counter-clockwise', value: '270' }, + ], + blankPosition: [ + { label: 'End', value: 'end' }, + { label: 'Beginning', value: 'start' }, + { label: 'After Page...', value: 'after' }, + ], + resolution: [ + { label: 'Standard (192 DPI)', value: '2.0' }, + { label: 'High (288 DPI)', value: '3.0' }, + { label: 'Ultra (384 DPI)', value: '4.0' }, + ], + language: getAvailableTesseractLanguageEntries().map(([code, name]) => ({ + label: name, + value: code, + })), + gridMode: [ + { label: '1x2 (Booklet)', value: '1x2' }, + { label: '2x2 (4-up)', value: '2x2' }, + { label: '2x4 (8-up)', value: '2x4' }, + { label: '4x4 (16-up)', value: '4x4' }, + ], + paperSize: [ + { label: 'Letter', value: 'Letter' }, + { label: 'A4', value: 'A4' }, + { label: 'A3', value: 'A3' }, + { label: 'Tabloid', value: 'Tabloid' }, + { label: 'Legal', value: 'Legal' }, + ], + rasterizeDpi: [ + { label: '72 (Screen)', value: '72' }, + { label: '150 (Default)', value: '150' }, + { label: '200 (Good)', value: '200' }, + { label: '300 (Print)', value: '300' }, + { label: '600 (High Quality)', value: '600' }, + ], + imageFormat: [ + { label: 'PNG (Lossless)', value: 'png' }, + { label: 'JPEG (Smaller file size)', value: 'jpeg' }, + ], + skewThreshold: [ + { label: '0.1° (Very Sensitive)', value: '0.1' }, + { label: '0.5° (Default)', value: '0.5' }, + { label: '1.0° (Normal)', value: '1.0' }, + { label: '2.0° (Less Sensitive)', value: '2.0' }, + ], + processingDpi: [ + { label: '100 (Fast)', value: '100' }, + { label: '150 (Default)', value: '150' }, + { label: '200 (Better)', value: '200' }, + { label: '300 (Best Quality)', value: '300' }, + ], + level: [ + { label: 'PDF/A-1b (Strict, no transparency)', value: 'PDF/A-1b' }, + { label: 'PDF/A-2b (Recommended)', value: 'PDF/A-2b' }, + { label: 'PDF/A-3b (Modern, allows attachments)', value: 'PDF/A-3b' }, + ], + algorithm: [ + { label: 'Condense (Smart, requires PyMuPDF)', value: 'condense' }, + { label: 'Photon (Rasterize pages)', value: 'photon' }, + ], + compressionLevel: [ + { label: 'Light', value: 'light' }, + { label: 'Balanced', value: 'balanced' }, + { label: 'Aggressive', value: 'aggressive' }, + { label: 'Extreme', value: 'extreme' }, + ], + redactMode: [ + { label: 'Search Text', value: 'text' }, + { label: 'Area (Coordinates)', value: 'area' }, + ], + }; + + const booleanControls = new Set([ + 'grayscale', + 'border', + 'margins', + 'separator', + 'sepia', + 'includeCcBcc', + 'includeAttachments', + 'binarize', + 'preFlatten', + 'flattenForms', + 'removeMetadata', + 'removeAnnotations', + 'removeJavascript', + 'removeEmbeddedFiles', + 'removeLayers', + 'removeLinks', + 'removeStructureTree', + 'removeMarkInfo', + 'removeFonts', + 'subsetFonts', + 'convertToGrayscale', + 'removeThumbnails', + ]); + const multiSelectDropdowns = new Set(['language']); + const advancedControls = new Set(['resolution', 'binarize', 'whitelist']); + + const colorControls = new Set([ + 'color', + 'borderColor', + 'backgroundColor', + 'separatorColor', + 'fontColor', + 'fillColor', + ]); + + const controlHints: Record = { + pages: 'e.g. 1-3, 5, 7-9', + whitelist: 'Limit recognized characters (leave empty for all)', + afterPage: 'Insert blank pages after this page number', + x0: 'Left edge in points (1 inch = 72 pts)', + y0: 'Top edge in points', + x1: 'Right edge in points', + y1: 'Bottom edge in points', + }; + + const inputClass = + 'w-full bg-gray-900 border border-gray-600 text-white rounded-md px-2 py-1.5 text-xs focus:border-indigo-500 focus:outline-none'; + + const conditionalVisibility: Record> = { + redactMode: { + text: ['text'], + area: ['x0', 'y0', 'x1', 'y1'], + }, + targetSize: { + Custom: ['customWidth', 'customHeight', 'customUnits'], + }, + }; + + const controlWrappers: Record = {}; + const hasAdvanced = controlEntries.some(([key]) => advancedControls.has(key)); + const advancedWrappers: HTMLElement[] = []; + + for (const [key, control] of controlEntries) { + const wrapper = document.createElement('div'); + controlWrappers[key] = wrapper; + const ctrl = control as { value?: unknown; type?: string }; + const currentValue = String(ctrl.value ?? ''); + + const controlLabel = document.createElement('label'); + controlLabel.className = 'block text-xs text-gray-400 mb-1'; + controlLabel.textContent = formatLabel(key); + wrapper.appendChild(controlLabel); + + if (dropdownOptions[key] && multiSelectDropdowns.has(key)) { + const selectedValues = new Set( + currentValue ? currentValue.split('+') : [] + ); + const container = document.createElement('div'); + container.className = 'flex flex-col gap-1'; + + const searchInput = document.createElement('input'); + searchInput.type = 'text'; + searchInput.placeholder = 'Search languages...'; + searchInput.className = inputClass; + container.appendChild(searchInput); + + const tagsDiv = document.createElement('div'); + tagsDiv.className = 'flex flex-wrap gap-1 min-h-[24px]'; + container.appendChild(tagsDiv); + + const listDiv = document.createElement('div'); + listDiv.className = + 'max-h-32 overflow-y-auto bg-gray-800 rounded border border-gray-600 mt-1'; + container.appendChild(listDiv); + + function updateTags() { + tagsDiv.innerHTML = ''; + for (const val of selectedValues) { + const opt = dropdownOptions[key].find((o) => o.value === val); + if (!opt) continue; + const tag = document.createElement('span'); + tag.className = + 'inline-flex items-center gap-1 px-2 py-0.5 rounded bg-indigo-600 text-white text-[10px]'; + tag.textContent = opt.label; + const removeBtn = document.createElement('button'); + removeBtn.type = 'button'; + removeBtn.textContent = '\u00d7'; + removeBtn.className = + 'text-white/70 hover:text-white text-xs leading-none'; + removeBtn.addEventListener('click', () => { + selectedValues.delete(val); + updateTags(); + updateCtrl(); + renderList(searchInput.value); + }); + tag.appendChild(removeBtn); + tagsDiv.appendChild(tag); + } + } + + function updateCtrl() { + (ctrl as { value: string }).value = + Array.from(selectedValues).join('+'); + } + + function renderList(filter: string) { + listDiv.innerHTML = ''; + const lowerFilter = filter.toLowerCase(); + for (const opt of dropdownOptions[key]) { + if (lowerFilter && !opt.label.toLowerCase().includes(lowerFilter)) + continue; + const label = document.createElement('label'); + label.className = + 'flex items-center gap-2 px-2 py-1 hover:bg-gray-700 cursor-pointer text-xs text-gray-300'; + const cb = document.createElement('input'); + cb.type = 'checkbox'; + cb.checked = selectedValues.has(opt.value); + cb.className = + 'w-3 h-3 rounded text-indigo-600 bg-gray-700 border-gray-600'; + cb.addEventListener('change', () => { + if (cb.checked) { + selectedValues.add(opt.value); + } else { + selectedValues.delete(opt.value); + } + updateTags(); + updateCtrl(); + }); + label.appendChild(cb); + label.appendChild(document.createTextNode(opt.label)); + listDiv.appendChild(label); + } + } + + searchInput.addEventListener('input', () => { + renderList(searchInput.value); + }); + + updateTags(); + renderList(''); + wrapper.appendChild(container); + } else if (dropdownOptions[key]) { + const select = document.createElement('select'); + select.className = inputClass; + for (const opt of dropdownOptions[key]) { + const option = document.createElement('option'); + option.value = opt.value; + option.textContent = opt.label; + if (currentValue === opt.value) option.selected = true; + select.appendChild(option); + } + select.addEventListener('change', () => { + (ctrl as { value: string }).value = select.value; + if (conditionalVisibility[key]) { + applyConditionalVisibility(key, select.value); + } + }); + wrapper.appendChild(select); + } else if (booleanControls.has(key)) { + const toggle = document.createElement('button'); + toggle.type = 'button'; + const isOn = currentValue === 'true'; + toggle.className = `relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full transition-colors duration-200 ${isOn ? 'bg-indigo-500' : 'bg-gray-600'}`; + const dot = document.createElement('span'); + dot.className = `pointer-events-none absolute top-[3px] left-[3px] h-[18px] w-[18px] rounded-full bg-white shadow-md transition-transform duration-200 ${isOn ? 'translate-x-5' : 'translate-x-0'}`; + toggle.appendChild(dot); + toggle.addEventListener('click', () => { + const newVal = + (ctrl as { value: string }).value === 'true' ? 'false' : 'true'; + (ctrl as { value: string }).value = newVal; + const on = newVal === 'true'; + toggle.className = `relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full transition-colors duration-200 ${on ? 'bg-indigo-500' : 'bg-gray-600'}`; + dot.className = `pointer-events-none absolute top-[3px] left-[3px] h-[18px] w-[18px] rounded-full bg-white shadow-md transition-transform duration-200 ${on ? 'translate-x-5' : 'translate-x-0'}`; + }); + wrapper.appendChild(toggle); + } else if (colorControls.has(key)) { + const colorRow = document.createElement('div'); + colorRow.className = 'flex items-center gap-2'; + const colorInput = document.createElement('input'); + colorInput.type = 'color'; + colorInput.value = currentValue || '#000000'; + colorInput.className = 'w-8 h-8 rounded bg-transparent cursor-pointer'; + const hexInput = document.createElement('input'); + hexInput.type = 'text'; + hexInput.value = currentValue || '#000000'; + hexInput.className = inputClass + ' flex-1'; + colorInput.addEventListener('input', () => { + hexInput.value = colorInput.value; + (ctrl as { value: string }).value = colorInput.value; + }); + hexInput.addEventListener('input', () => { + if (/^#[0-9a-fA-F]{6}$/.test(hexInput.value)) { + colorInput.value = hexInput.value; + } + (ctrl as { value: string }).value = hexInput.value; + }); + colorRow.appendChild(colorInput); + colorRow.appendChild(hexInput); + wrapper.appendChild(colorRow); + } else if (ctrl.type === 'number' || typeof ctrl.value === 'number') { + const input = document.createElement('input'); + input.type = 'number'; + input.className = inputClass; + input.value = currentValue; + input.addEventListener('input', () => { + const num = parseFloat(input.value); + if (!isNaN(num)) { + (ctrl as { value: number }).value = num; + } + }); + wrapper.appendChild(input); + } else { + const input = document.createElement('input'); + const isPasswordField = key === 'password' || key === 'ownerPassword'; + input.type = isPasswordField ? 'password' : 'text'; + input.className = inputClass; + input.value = currentValue; + input.addEventListener('input', () => { + (ctrl as { value: string }).value = input.value; + }); + wrapper.appendChild(input); + } + + if (controlHints[key]) { + const hint = document.createElement('p'); + hint.className = 'text-[10px] text-gray-500 mt-1'; + hint.textContent = controlHints[key]; + wrapper.appendChild(hint); + } + + if (advancedControls.has(key)) { + advancedWrappers.push(wrapper); + } else { + content.appendChild(wrapper); + } + } + + function applyConditionalVisibility( + dropdownKey: string, + selectedValue: string + ) { + const mapping = conditionalVisibility[dropdownKey]; + if (!mapping) return; + const allControlled = new Set(Object.values(mapping).flat()); + for (const controlKey of allControlled) { + const el = controlWrappers[controlKey]; + if (el) el.style.display = 'none'; + } + const visible = mapping[selectedValue] ?? []; + for (const controlKey of visible) { + const el = controlWrappers[controlKey]; + if (el) el.style.display = ''; + } + } + + for (const [dropdownKey, mapping] of Object.entries(conditionalVisibility)) { + const ctrl = controlEntries.find(([k]) => k === dropdownKey)?.[1] as + | { value?: unknown } + | undefined; + if (ctrl) { + applyConditionalVisibility(dropdownKey, String(ctrl.value ?? '')); + } + } + + if (hasAdvanced && advancedWrappers.length > 0) { + const details = document.createElement('details'); + details.className = + 'bg-gray-800/50 border border-gray-700 rounded-lg p-2 mt-1'; + const summary = document.createElement('summary'); + summary.className = + 'text-xs font-medium text-gray-400 cursor-pointer select-none flex items-center justify-between'; + const summaryText = document.createElement('span'); + summaryText.textContent = 'Advanced Settings'; + summary.appendChild(summaryText); + const chevron = document.createElement('i'); + chevron.className = + 'ph ph-caret-down text-xs text-gray-500 transition-transform duration-200'; + summary.appendChild(chevron); + details.addEventListener('toggle', () => { + chevron.style.transform = details.open + ? 'rotate(180deg)' + : 'rotate(0deg)'; + }); + details.appendChild(summary); + const advancedContent = document.createElement('div'); + advancedContent.className = 'mt-2 space-y-3'; + for (const w of advancedWrappers) { + advancedContent.appendChild(w); + } + details.appendChild(advancedContent); + content.appendChild(details); + } +} + +function formatLabel(key: string): string { + return key + .replace(/([A-Z])/g, ' $1') + .replace(/^./, (c) => c.toUpperCase()) + .trim(); +} diff --git a/src/js/logic/png-to-pdf-page.ts b/src/js/logic/png-to-pdf-page.ts index eb9e34a..a286e67 100644 --- a/src/js/logic/png-to-pdf-page.ts +++ b/src/js/logic/png-to-pdf-page.ts @@ -1,248 +1,257 @@ import { createIcons, icons } from 'lucide'; import { showAlert, showLoader, hideLoader } from '../ui.js'; -import { downloadFile, readFileAsArrayBuffer, formatBytes } from '../utils/helpers.js'; +import { + downloadFile, + readFileAsArrayBuffer, + formatBytes, +} from '../utils/helpers.js'; import { PDFDocument as PDFLibDocument } from 'pdf-lib'; +import { + getSelectedQuality, + compressImageBytes, +} from '../utils/image-compress.js'; let files: File[] = []; if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', initializePage); + document.addEventListener('DOMContentLoaded', initializePage); } else { - initializePage(); + initializePage(); } function initializePage() { - createIcons({ icons }); + createIcons({ icons }); - const fileInput = document.getElementById('file-input') as HTMLInputElement; - const dropZone = document.getElementById('drop-zone'); - const addMoreBtn = document.getElementById('add-more-btn'); - const clearFilesBtn = document.getElementById('clear-files-btn'); - const processBtn = document.getElementById('process-btn'); + const fileInput = document.getElementById('file-input') as HTMLInputElement; + const dropZone = document.getElementById('drop-zone'); + const addMoreBtn = document.getElementById('add-more-btn'); + const clearFilesBtn = document.getElementById('clear-files-btn'); + const processBtn = document.getElementById('process-btn'); - if (fileInput) { - fileInput.addEventListener('change', handleFileUpload); - } + if (fileInput) { + fileInput.addEventListener('change', handleFileUpload); + } - 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) { - handleFiles(droppedFiles); - } - }); - - // Clear value on click to allow re-selecting the same file - fileInput?.addEventListener('click', () => { - if (fileInput) fileInput.value = ''; - }); - } - - if (addMoreBtn) { - addMoreBtn.addEventListener('click', () => { - fileInput?.click(); - }); - } - - if (clearFilesBtn) { - clearFilesBtn.addEventListener('click', () => { - files = []; - updateUI(); - }); - } - - if (processBtn) { - processBtn.addEventListener('click', convertToPdf); - } - - document.getElementById('back-to-tools')?.addEventListener('click', () => { - window.location.href = import.meta.env.BASE_URL; + 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) { + handleFiles(droppedFiles); + } + }); + + // Clear value on click to allow re-selecting the same file + fileInput?.addEventListener('click', () => { + if (fileInput) fileInput.value = ''; + }); + } + + if (addMoreBtn) { + addMoreBtn.addEventListener('click', () => { + fileInput?.click(); + }); + } + + if (clearFilesBtn) { + clearFilesBtn.addEventListener('click', () => { + files = []; + updateUI(); + }); + } + + if (processBtn) { + processBtn.addEventListener('click', convertToPdf); + } + + document.getElementById('back-to-tools')?.addEventListener('click', () => { + window.location.href = import.meta.env.BASE_URL; + }); } function handleFileUpload(e: Event) { - const input = e.target as HTMLInputElement; - if (input.files && input.files.length > 0) { - handleFiles(input.files); - } + const input = e.target as HTMLInputElement; + if (input.files && input.files.length > 0) { + handleFiles(input.files); + } } function handleFiles(newFiles: FileList) { - const validFiles = Array.from(newFiles).filter(file => - file.type === 'image/png' || file.name.toLowerCase().endsWith('.png') + const validFiles = Array.from(newFiles).filter( + (file) => + file.type === 'image/png' || file.name.toLowerCase().endsWith('.png') + ); + + if (validFiles.length < newFiles.length) { + showAlert( + 'Invalid Files', + 'Some files were skipped. Only PNG images are allowed.' ); + } - if (validFiles.length < newFiles.length) { - showAlert('Invalid Files', 'Some files were skipped. Only PNG images are allowed.'); - } - - if (validFiles.length > 0) { - files = [...files, ...validFiles]; - updateUI(); - } + if (validFiles.length > 0) { + files = [...files, ...validFiles]; + updateUI(); + } } const resetState = () => { - files = []; - updateUI(); + files = []; + updateUI(); }; function updateUI() { - const fileDisplayArea = document.getElementById('file-display-area'); - const fileControls = document.getElementById('file-controls'); - const optionsDiv = document.getElementById('jpg-to-pdf-options'); + const fileDisplayArea = document.getElementById('file-display-area'); + const fileControls = document.getElementById('file-controls'); + const optionsDiv = document.getElementById('jpg-to-pdf-options'); - if (!fileDisplayArea || !fileControls || !optionsDiv) return; + if (!fileDisplayArea || !fileControls || !optionsDiv) return; - fileDisplayArea.innerHTML = ''; + fileDisplayArea.innerHTML = ''; - if (files.length > 0) { - fileControls.classList.remove('hidden'); - optionsDiv.classList.remove('hidden'); + if (files.length > 0) { + fileControls.classList.remove('hidden'); + optionsDiv.classList.remove('hidden'); - files.forEach((file, index) => { - const fileDiv = document.createElement('div'); - fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm'; + files.forEach((file, index) => { + const fileDiv = document.createElement('div'); + fileDiv.className = + 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm'; - const infoContainer = document.createElement('div'); - infoContainer.className = 'flex items-center gap-2 overflow-hidden'; + const infoContainer = document.createElement('div'); + infoContainer.className = 'flex items-center gap-2 overflow-hidden'; - const nameSpan = document.createElement('span'); - nameSpan.className = 'truncate font-medium text-gray-200'; - nameSpan.textContent = file.name; + const nameSpan = document.createElement('span'); + nameSpan.className = 'truncate font-medium text-gray-200'; + nameSpan.textContent = file.name; - const sizeSpan = document.createElement('span'); - sizeSpan.className = 'flex-shrink-0 text-gray-400 text-xs'; - sizeSpan.textContent = `(${formatBytes(file.size)})`; + const sizeSpan = document.createElement('span'); + sizeSpan.className = 'flex-shrink-0 text-gray-400 text-xs'; + sizeSpan.textContent = `(${formatBytes(file.size)})`; - infoContainer.append(nameSpan, sizeSpan); + infoContainer.append(nameSpan, sizeSpan); - const removeBtn = document.createElement('button'); - removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0'; - removeBtn.innerHTML = ''; - removeBtn.onclick = () => { - files = files.filter((_, i) => i !== index); - updateUI(); - }; + const removeBtn = document.createElement('button'); + removeBtn.className = + 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0'; + removeBtn.innerHTML = ''; + removeBtn.onclick = () => { + files = files.filter((_, i) => i !== index); + updateUI(); + }; - fileDiv.append(infoContainer, removeBtn); - fileDisplayArea.appendChild(fileDiv); - }); - createIcons({ icons }); - } else { - fileControls.classList.add('hidden'); - optionsDiv.classList.add('hidden'); - } + fileDiv.append(infoContainer, removeBtn); + fileDisplayArea.appendChild(fileDiv); + }); + createIcons({ icons }); + } else { + fileControls.classList.add('hidden'); + optionsDiv.classList.add('hidden'); + } } function sanitizeImageAsJpeg(imageBytes: any) { - return new Promise((resolve, reject) => { - const blob = new Blob([imageBytes]); - const imageUrl = URL.createObjectURL(blob); - const img = new Image(); + return new Promise((resolve, reject) => { + const blob = new Blob([imageBytes]); + const imageUrl = URL.createObjectURL(blob); + const img = new Image(); - img.onload = () => { - const canvas = document.createElement('canvas'); - canvas.width = img.naturalWidth; - canvas.height = img.naturalHeight; - const ctx = canvas.getContext('2d'); - ctx.drawImage(img, 0, 0); + img.onload = () => { + const canvas = document.createElement('canvas'); + canvas.width = img.naturalWidth; + canvas.height = img.naturalHeight; + const ctx = canvas.getContext('2d'); + ctx.drawImage(img, 0, 0); - canvas.toBlob( - async (jpegBlob) => { - if (!jpegBlob) { - return reject(new Error('Canvas toBlob conversion failed.')); - } - const arrayBuffer = await jpegBlob.arrayBuffer(); - resolve(new Uint8Array(arrayBuffer)); - }, - 'image/jpeg', - 0.9 - ); - URL.revokeObjectURL(imageUrl); - }; + canvas.toBlob( + async (jpegBlob) => { + if (!jpegBlob) { + return reject(new Error('Canvas toBlob conversion failed.')); + } + const arrayBuffer = await jpegBlob.arrayBuffer(); + resolve(new Uint8Array(arrayBuffer)); + }, + 'image/jpeg', + 0.9 + ); + URL.revokeObjectURL(imageUrl); + }; - img.onerror = () => { - URL.revokeObjectURL(imageUrl); - reject( - new Error( - 'The provided file could not be loaded as an image. It may be corrupted.' - ) - ); - }; + img.onerror = () => { + URL.revokeObjectURL(imageUrl); + reject( + new Error( + 'The provided file could not be loaded as an image. It may be corrupted.' + ) + ); + }; - img.src = imageUrl; - }); + img.src = imageUrl; + }); } async function convertToPdf() { - if (files.length === 0) { - showAlert('No Files', 'Please select at least one JPG file.'); - return; - } + if (files.length === 0) { + showAlert('No Files', 'Please select at least one JPG file.'); + return; + } - showLoader('Creating PDF from JPGs...'); + showLoader('Creating PDF from JPGs...'); - try { - const pdfDoc = await PDFLibDocument.create(); + try { + const pdfDoc = await PDFLibDocument.create(); + const quality = getSelectedQuality(); - for (const file of files) { - const originalBytes = await readFileAsArrayBuffer(file); - let jpgImage; + for (const file of files) { + const originalBytes = await readFileAsArrayBuffer(file); + const compressed = await compressImageBytes( + new Uint8Array(originalBytes as ArrayBuffer), + quality + ); + let embeddedImage; - try { - jpgImage = await pdfDoc.embedJpg(originalBytes as Uint8Array); - } catch (e) { - showAlert( - 'Warning', - `Direct JPG embedding failed for ${file.name}, attempting to sanitize...` - ); - try { - const sanitizedBytes = await sanitizeImageAsJpeg(originalBytes); - jpgImage = await pdfDoc.embedJpg(sanitizedBytes as Uint8Array); - } catch (fallbackError) { - console.error( - `Failed to process ${file.name} after sanitization:`, - fallbackError - ); - throw new Error( - `Could not process "${file.name}". The file may be corrupted.` - ); - } - } - - const page = pdfDoc.addPage([jpgImage.width, jpgImage.height]); - page.drawImage(jpgImage, { - x: 0, - y: 0, - width: jpgImage.width, - height: jpgImage.height, - }); + if (compressed.type === 'jpeg') { + embeddedImage = await pdfDoc.embedJpg(compressed.bytes); + } else { + try { + embeddedImage = await pdfDoc.embedPng(compressed.bytes); + } catch { + const fallback = await sanitizeImageAsJpeg(originalBytes); + embeddedImage = await pdfDoc.embedJpg(fallback as Uint8Array); } + } - const pdfBytes = await pdfDoc.save(); - downloadFile( - new Blob([new Uint8Array(pdfBytes)], { type: 'application/pdf' }), - 'from_jpgs.pdf' - ); - showAlert('Success', 'PDF created successfully!', 'success', () => { - resetState(); - }); - } catch (e: any) { - console.error(e); - showAlert('Conversion Error', e.message); - } finally { - hideLoader(); + const page = pdfDoc.addPage([embeddedImage.width, embeddedImage.height]); + page.drawImage(embeddedImage, { + x: 0, + y: 0, + width: embeddedImage.width, + height: embeddedImage.height, + }); } + + const pdfBytes = await pdfDoc.save(); + downloadFile( + new Blob([new Uint8Array(pdfBytes)], { type: 'application/pdf' }), + 'from_jpgs.pdf' + ); + showAlert('Success', 'PDF created successfully!', 'success', () => { + resetState(); + }); + } catch (e: any) { + console.error(e); + showAlert('Conversion Error', e.message); + } finally { + hideLoader(); + } } diff --git a/src/js/logic/prepare-pdf-for-ai-page.ts b/src/js/logic/prepare-pdf-for-ai-page.ts index a1981e4..464f522 100644 --- a/src/js/logic/prepare-pdf-for-ai-page.ts +++ b/src/js/logic/prepare-pdf-for-ai-page.ts @@ -1,204 +1,237 @@ import { showLoader, hideLoader, showAlert } from '../ui.js'; -import { downloadFile, readFileAsArrayBuffer, formatBytes, getPDFDocument } from '../utils/helpers.js'; +import { + downloadFile, + readFileAsArrayBuffer, + formatBytes, + getPDFDocument, +} from '../utils/helpers.js'; import { state } from '../state.js'; import { createIcons, icons } from 'lucide'; -import { PyMuPDF } from '@bentopdf/pymupdf-wasm'; -import { getWasmBaseUrl } from '../config/wasm-cdn-config.js'; - -const pymupdf = new PyMuPDF(getWasmBaseUrl('pymupdf')); +import { isWasmAvailable, getWasmBaseUrl } from '../config/wasm-cdn-config.js'; +import { showWasmRequiredDialog } from '../utils/wasm-provider.js'; +import { loadPyMuPDF, isPyMuPDFAvailable } from '../utils/pymupdf-loader.js'; document.addEventListener('DOMContentLoaded', () => { - const fileInput = document.getElementById('file-input') as HTMLInputElement; - const dropZone = document.getElementById('drop-zone'); - const processBtn = document.getElementById('process-btn'); - const fileDisplayArea = document.getElementById('file-display-area'); - const extractOptions = document.getElementById('extract-options'); - const fileControls = document.getElementById('file-controls'); - const addMoreBtn = document.getElementById('add-more-btn'); - const clearFilesBtn = document.getElementById('clear-files-btn'); - const backBtn = document.getElementById('back-to-tools'); + const fileInput = document.getElementById('file-input') as HTMLInputElement; + const dropZone = document.getElementById('drop-zone'); + const processBtn = document.getElementById('process-btn'); + const fileDisplayArea = document.getElementById('file-display-area'); + const extractOptions = document.getElementById('extract-options'); + const fileControls = document.getElementById('file-controls'); + const addMoreBtn = document.getElementById('add-more-btn'); + const clearFilesBtn = document.getElementById('clear-files-btn'); + const backBtn = document.getElementById('back-to-tools'); - if (backBtn) { - backBtn.addEventListener('click', () => { - window.location.href = import.meta.env.BASE_URL; - }); - } + if (backBtn) { + backBtn.addEventListener('click', () => { + window.location.href = import.meta.env.BASE_URL; + }); + } - const updateUI = async () => { - if (!fileDisplayArea || !extractOptions || !processBtn || !fileControls) return; + const updateUI = async () => { + if (!fileDisplayArea || !extractOptions || !processBtn || !fileControls) + return; - if (state.files.length > 0) { - fileDisplayArea.innerHTML = ''; + if (state.files.length > 0) { + fileDisplayArea.innerHTML = ''; - for (let index = 0; index < state.files.length; index++) { - const file = state.files[index]; - const fileDiv = document.createElement('div'); - fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm'; + for (let index = 0; index < state.files.length; index++) { + const file = state.files[index]; + const fileDiv = document.createElement('div'); + fileDiv.className = + 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm'; - const infoContainer = document.createElement('div'); - infoContainer.className = 'flex flex-col overflow-hidden'; + const infoContainer = document.createElement('div'); + infoContainer.className = 'flex flex-col overflow-hidden'; - const nameSpan = document.createElement('div'); - nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1'; - nameSpan.textContent = file.name; + const nameSpan = document.createElement('div'); + nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1'; + nameSpan.textContent = file.name; - const metaSpan = document.createElement('div'); - metaSpan.className = 'text-xs text-gray-400'; - metaSpan.textContent = `${formatBytes(file.size)} • Loading pages...`; + const metaSpan = document.createElement('div'); + metaSpan.className = 'text-xs text-gray-400'; + metaSpan.textContent = `${formatBytes(file.size)} • Loading pages...`; - infoContainer.append(nameSpan, metaSpan); + infoContainer.append(nameSpan, metaSpan); - const removeBtn = document.createElement('button'); - removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0'; - removeBtn.innerHTML = ''; - removeBtn.onclick = () => { - state.files = state.files.filter((_, i) => i !== index); - updateUI(); - }; + const removeBtn = document.createElement('button'); + removeBtn.className = + 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0'; + removeBtn.innerHTML = ''; + removeBtn.onclick = () => { + state.files = state.files.filter((_, i) => i !== index); + updateUI(); + }; - fileDiv.append(infoContainer, removeBtn); - fileDisplayArea.appendChild(fileDiv); + fileDiv.append(infoContainer, removeBtn); + fileDisplayArea.appendChild(fileDiv); - try { - const arrayBuffer = await readFileAsArrayBuffer(file); - const pdfDoc = await getPDFDocument({ data: arrayBuffer }).promise; - metaSpan.textContent = `${formatBytes(file.size)} • ${pdfDoc.numPages} pages`; - } catch (error) { - console.error('Error loading PDF:', error); - metaSpan.textContent = `${formatBytes(file.size)} • Could not load page count`; - } - } - - createIcons({ icons }); - fileControls.classList.remove('hidden'); - extractOptions.classList.remove('hidden'); - (processBtn as HTMLButtonElement).disabled = false; - } else { - fileDisplayArea.innerHTML = ''; - fileControls.classList.add('hidden'); - extractOptions.classList.add('hidden'); - (processBtn as HTMLButtonElement).disabled = true; - } - }; - - const resetState = () => { - state.files = []; - state.pdfDoc = null; - updateUI(); - }; - - const extractForAI = async () => { try { - if (state.files.length === 0) { - showAlert('No Files', 'Please select at least one PDF file.'); - return; - } - - showLoader('Loading engine...'); - await pymupdf.load(); - - const total = state.files.length; - let completed = 0; - let failed = 0; - - if (total === 1) { - const file = state.files[0]; - showLoader(`Extracting ${file.name} for AI...`); - - const llamaDocs = await (pymupdf as any).pdfToLlamaIndex(file); - const outName = file.name.replace(/\.pdf$/i, '') + '_llm.json'; - const jsonContent = JSON.stringify(llamaDocs, null, 2); - downloadFile(new Blob([jsonContent], { type: 'application/json' }), outName); - - hideLoader(); - showAlert('Extraction Complete', `Successfully extracted PDF for AI/LLM use.`, 'success', () => resetState()); - } else { - // Multiple files - create ZIP - const JSZip = (await import('jszip')).default; - const zip = new JSZip(); - - for (const file of state.files) { - try { - showLoader(`Extracting ${file.name} for AI (${completed + 1}/${total})...`); - - const llamaDocs = await (pymupdf as any).pdfToLlamaIndex(file); - const outName = file.name.replace(/\.pdf$/i, '') + '_llm.json'; - const jsonContent = JSON.stringify(llamaDocs, null, 2); - zip.file(outName, jsonContent); - - completed++; - } catch (error) { - console.error(`Failed to extract ${file.name}:`, error); - failed++; - } - } - - showLoader('Creating ZIP archive...'); - const zipBlob = await zip.generateAsync({ type: 'blob' }); - - downloadFile(zipBlob, 'pdf-for-ai.zip'); - - hideLoader(); - - if (failed === 0) { - showAlert('Extraction Complete', `Successfully extracted ${completed} PDF(s) for AI/LLM use.`, 'success', () => resetState()); - } else { - showAlert('Extraction Partial', `Extracted ${completed} PDF(s), failed ${failed}.`, 'warning', () => resetState()); - } - } - } catch (e: any) { - hideLoader(); - showAlert('Error', `An error occurred during extraction. Error: ${e.message}`); + const arrayBuffer = await readFileAsArrayBuffer(file); + const pdfDoc = await getPDFDocument({ data: arrayBuffer }).promise; + metaSpan.textContent = `${formatBytes(file.size)} • ${pdfDoc.numPages} pages`; + } catch (error) { + console.error('Error loading PDF:', error); + metaSpan.textContent = `${formatBytes(file.size)} • Could not load page count`; } - }; + } - const handleFileSelect = (files: FileList | null) => { - if (files && files.length > 0) { - const pdfFiles = Array.from(files).filter(f => f.type === 'application/pdf' || f.name.toLowerCase().endsWith('.pdf')); - if (pdfFiles.length > 0) { - state.files = [...state.files, ...pdfFiles]; - updateUI(); - } + createIcons({ icons }); + fileControls.classList.remove('hidden'); + extractOptions.classList.remove('hidden'); + (processBtn as HTMLButtonElement).disabled = false; + } else { + fileDisplayArea.innerHTML = ''; + fileControls.classList.add('hidden'); + extractOptions.classList.add('hidden'); + (processBtn as HTMLButtonElement).disabled = true; + } + }; + + const resetState = () => { + state.files = []; + state.pdfDoc = null; + updateUI(); + }; + + const extractForAI = async () => { + try { + if (state.files.length === 0) { + showAlert('No Files', 'Please select at least one PDF file.'); + return; + } + + showLoader('Loading engine...'); + const pymupdf = await loadPyMuPDF(); + + const total = state.files.length; + let completed = 0; + let failed = 0; + + if (total === 1) { + const file = state.files[0]; + showLoader(`Extracting ${file.name} for AI...`); + + const llamaDocs = await (pymupdf as any).pdfToLlamaIndex(file); + const outName = file.name.replace(/\.pdf$/i, '') + '_llm.json'; + const jsonContent = JSON.stringify(llamaDocs, null, 2); + downloadFile( + new Blob([jsonContent], { type: 'application/json' }), + outName + ); + + hideLoader(); + showAlert( + 'Extraction Complete', + `Successfully extracted PDF for AI/LLM use.`, + 'success', + () => resetState() + ); + } else { + // Multiple files - create ZIP + const JSZip = (await import('jszip')).default; + const zip = new JSZip(); + + for (const file of state.files) { + try { + showLoader( + `Extracting ${file.name} for AI (${completed + 1}/${total})...` + ); + + const llamaDocs = await (pymupdf as any).pdfToLlamaIndex(file); + const outName = file.name.replace(/\.pdf$/i, '') + '_llm.json'; + const jsonContent = JSON.stringify(llamaDocs, null, 2); + zip.file(outName, jsonContent); + + completed++; + } catch (error) { + console.error(`Failed to extract ${file.name}:`, error); + failed++; + } } - }; - if (fileInput && dropZone) { - fileInput.addEventListener('change', (e) => { - handleFileSelect((e.target as HTMLInputElement).files); - }); + showLoader('Creating ZIP archive...'); + const zipBlob = await zip.generateAsync({ type: 'blob' }); - dropZone.addEventListener('dragover', (e) => { - e.preventDefault(); - dropZone.classList.add('bg-gray-700'); - }); + downloadFile(zipBlob, 'pdf-for-ai.zip'); - dropZone.addEventListener('dragleave', (e) => { - e.preventDefault(); - dropZone.classList.remove('bg-gray-700'); - }); + hideLoader(); - dropZone.addEventListener('drop', (e) => { - e.preventDefault(); - dropZone.classList.remove('bg-gray-700'); - handleFileSelect(e.dataTransfer?.files ?? null); - }); - - fileInput.addEventListener('click', () => { - fileInput.value = ''; - }); + if (failed === 0) { + showAlert( + 'Extraction Complete', + `Successfully extracted ${completed} PDF(s) for AI/LLM use.`, + 'success', + () => resetState() + ); + } else { + showAlert( + 'Extraction Partial', + `Extracted ${completed} PDF(s), failed ${failed}.`, + 'warning', + () => resetState() + ); + } + } + } catch (e: any) { + hideLoader(); + showAlert( + 'Error', + `An error occurred during extraction. Error: ${e.message}` + ); } + }; - if (addMoreBtn) { - addMoreBtn.addEventListener('click', () => { - fileInput.click(); - }); + const handleFileSelect = (files: FileList | null) => { + if (files && files.length > 0) { + const pdfFiles = Array.from(files).filter( + (f) => + f.type === 'application/pdf' || f.name.toLowerCase().endsWith('.pdf') + ); + if (pdfFiles.length > 0) { + state.files = [...state.files, ...pdfFiles]; + updateUI(); + } } + }; - if (clearFilesBtn) { - clearFilesBtn.addEventListener('click', resetState); - } + if (fileInput && dropZone) { + fileInput.addEventListener('change', (e) => { + handleFileSelect((e.target as HTMLInputElement).files); + }); - if (processBtn) { - processBtn.addEventListener('click', extractForAI); - } + dropZone.addEventListener('dragover', (e) => { + e.preventDefault(); + dropZone.classList.add('bg-gray-700'); + }); + + dropZone.addEventListener('dragleave', (e) => { + e.preventDefault(); + dropZone.classList.remove('bg-gray-700'); + }); + + dropZone.addEventListener('drop', (e) => { + e.preventDefault(); + dropZone.classList.remove('bg-gray-700'); + handleFileSelect(e.dataTransfer?.files ?? null); + }); + + fileInput.addEventListener('click', () => { + fileInput.value = ''; + }); + } + + if (addMoreBtn) { + addMoreBtn.addEventListener('click', () => { + fileInput.click(); + }); + } + + if (clearFilesBtn) { + clearFilesBtn.addEventListener('click', resetState); + } + + if (processBtn) { + processBtn.addEventListener('click', extractForAI); + } }); diff --git a/src/js/logic/psd-to-pdf-page.ts b/src/js/logic/psd-to-pdf-page.ts index 5f25ff8..6a17d23 100644 --- a/src/js/logic/psd-to-pdf-page.ts +++ b/src/js/logic/psd-to-pdf-page.ts @@ -2,133 +2,165 @@ import { showLoader, hideLoader, showAlert } from '../ui.js'; import { downloadFile, formatBytes } from '../utils/helpers.js'; import { state } from '../state.js'; import { createIcons, icons } from 'lucide'; -import { PyMuPDF } from '@bentopdf/pymupdf-wasm'; -import { getWasmBaseUrl } from '../config/wasm-cdn-config.js'; +import { isWasmAvailable, getWasmBaseUrl } from '../config/wasm-cdn-config.js'; +import { showWasmRequiredDialog } from '../utils/wasm-provider.js'; +import { loadPyMuPDF, isPyMuPDFAvailable } from '../utils/pymupdf-loader.js'; const ACCEPTED_EXTENSIONS = ['.psd']; const FILETYPE_NAME = 'PSD'; -let pymupdf: PyMuPDF | null = null; +let pymupdf: any = null; -async function ensurePyMuPDF(): Promise { - if (!pymupdf) { - pymupdf = new PyMuPDF(getWasmBaseUrl('pymupdf')); - await pymupdf.load(); - } - return pymupdf; +async function ensurePyMuPDF(): Promise { + if (!pymupdf) { + pymupdf = await loadPyMuPDF(); + } + return pymupdf; } document.addEventListener('DOMContentLoaded', () => { - const fileInput = document.getElementById('file-input') as HTMLInputElement; - const dropZone = document.getElementById('drop-zone'); - const processBtn = document.getElementById('process-btn'); - const fileDisplayArea = document.getElementById('file-display-area'); - const fileControls = document.getElementById('file-controls'); - const addMoreBtn = document.getElementById('add-more-btn'); - const clearFilesBtn = document.getElementById('clear-files-btn'); - const backBtn = document.getElementById('back-to-tools'); + const fileInput = document.getElementById('file-input') as HTMLInputElement; + const dropZone = document.getElementById('drop-zone'); + const processBtn = document.getElementById('process-btn'); + const fileDisplayArea = document.getElementById('file-display-area'); + const fileControls = document.getElementById('file-controls'); + const addMoreBtn = document.getElementById('add-more-btn'); + const clearFilesBtn = document.getElementById('clear-files-btn'); + const backBtn = document.getElementById('back-to-tools'); - if (backBtn) { - backBtn.addEventListener('click', () => { - window.location.href = import.meta.env.BASE_URL; - }); + if (backBtn) { + backBtn.addEventListener('click', () => { + window.location.href = import.meta.env.BASE_URL; + }); + } + + const updateUI = async () => { + if (!fileDisplayArea || !processBtn || !fileControls) return; + if (state.files.length > 0) { + fileDisplayArea.innerHTML = ''; + for (let index = 0; index < state.files.length; index++) { + const file = state.files[index]; + const fileDiv = document.createElement('div'); + fileDiv.className = + 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm'; + const infoContainer = document.createElement('div'); + infoContainer.className = 'flex flex-col overflow-hidden'; + const nameSpan = document.createElement('div'); + nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1'; + nameSpan.textContent = file.name; + const metaSpan = document.createElement('div'); + metaSpan.className = 'text-xs text-gray-400'; + metaSpan.textContent = formatBytes(file.size); + 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 = () => { + state.files = state.files.filter((_, i) => i !== index); + updateUI(); + }; + fileDiv.append(infoContainer, removeBtn); + fileDisplayArea.appendChild(fileDiv); + } + createIcons({ icons }); + fileControls.classList.remove('hidden'); + processBtn.classList.remove('hidden'); + } else { + fileDisplayArea.innerHTML = ''; + fileControls.classList.add('hidden'); + processBtn.classList.add('hidden'); } + }; - const updateUI = async () => { - if (!fileDisplayArea || !processBtn || !fileControls) return; - if (state.files.length > 0) { - fileDisplayArea.innerHTML = ''; - for (let index = 0; index < state.files.length; index++) { - const file = state.files[index]; - const fileDiv = document.createElement('div'); - fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm'; - const infoContainer = document.createElement('div'); - infoContainer.className = 'flex flex-col overflow-hidden'; - const nameSpan = document.createElement('div'); - nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1'; - nameSpan.textContent = file.name; - const metaSpan = document.createElement('div'); - metaSpan.className = 'text-xs text-gray-400'; - metaSpan.textContent = formatBytes(file.size); - 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 = () => { - state.files = state.files.filter((_, i) => i !== index); - updateUI(); - }; - fileDiv.append(infoContainer, removeBtn); - fileDisplayArea.appendChild(fileDiv); - } - createIcons({ icons }); - fileControls.classList.remove('hidden'); - processBtn.classList.remove('hidden'); - } else { - fileDisplayArea.innerHTML = ''; - fileControls.classList.add('hidden'); - processBtn.classList.add('hidden'); - } - }; + const resetState = () => { + state.files = []; + updateUI(); + }; - const resetState = () => { - state.files = []; + const convert = async () => { + if (state.files.length === 0) { + showAlert( + 'No Files', + `Please select at least one ${FILETYPE_NAME} file.` + ); + return; + } + try { + showLoader('Loading engine...'); + const mupdf = await ensurePyMuPDF(); + + if (state.files.length === 1) { + const file = state.files[0]; + showLoader(`Converting ${file.name}...`); + const pdfBlob = await mupdf.imageToPdf(file, { imageType: 'psd' }); + const baseName = file.name.replace(/\.[^/.]+$/, ''); + downloadFile(pdfBlob, `${baseName}.pdf`); + hideLoader(); + showAlert( + 'Conversion Complete', + `Successfully converted ${file.name} to PDF.`, + 'success', + () => resetState() + ); + } else { + showLoader('Converting multiple files...'); + const pdfBlob = await mupdf.imagesToPdf(state.files); + downloadFile(pdfBlob, 'psd_to_pdf.pdf'); + hideLoader(); + showAlert( + 'Conversion Complete', + `Successfully converted ${state.files.length} PSD files to a single PDF.`, + 'success', + () => resetState() + ); + } + } catch (err) { + hideLoader(); + const message = err instanceof Error ? err.message : 'Unknown error'; + console.error(`[${FILETYPE_NAME}ToPDF] Error:`, err); + showAlert( + 'Error', + `An error occurred during conversion. Error: ${message}` + ); + } + }; + + const handleFileSelect = (files: FileList | null) => { + if (files && files.length > 0) { + const validFiles = Array.from(files).filter((file) => { + const ext = '.' + file.name.split('.').pop()?.toLowerCase(); + return ACCEPTED_EXTENSIONS.includes(ext); + }); + if (validFiles.length > 0) { + state.files = [...state.files, ...validFiles]; updateUI(); - }; - - const convert = async () => { - if (state.files.length === 0) { - showAlert('No Files', `Please select at least one ${FILETYPE_NAME} file.`); - return; - } - try { - showLoader('Loading engine...'); - const mupdf = await ensurePyMuPDF(); - - if (state.files.length === 1) { - const file = state.files[0]; - showLoader(`Converting ${file.name}...`); - const pdfBlob = await mupdf.imageToPdf(file, { imageType: 'psd' }); - const baseName = file.name.replace(/\.[^/.]+$/, ''); - downloadFile(pdfBlob, `${baseName}.pdf`); - hideLoader(); - showAlert('Conversion Complete', `Successfully converted ${file.name} to PDF.`, 'success', () => resetState()); - } else { - showLoader('Converting multiple files...'); - const pdfBlob = await mupdf.imagesToPdf(state.files); - downloadFile(pdfBlob, 'psd_to_pdf.pdf'); - hideLoader(); - showAlert('Conversion Complete', `Successfully converted ${state.files.length} PSD files to a single PDF.`, 'success', () => resetState()); - } - } catch (err) { - hideLoader(); - const message = err instanceof Error ? err.message : 'Unknown error'; - console.error(`[${FILETYPE_NAME}ToPDF] Error:`, err); - showAlert('Error', `An error occurred during conversion. Error: ${message}`); - } - }; - - const handleFileSelect = (files: FileList | null) => { - if (files && files.length > 0) { - const validFiles = Array.from(files).filter(file => { - const ext = '.' + file.name.split('.').pop()?.toLowerCase(); - return ACCEPTED_EXTENSIONS.includes(ext); - }); - if (validFiles.length > 0) { - state.files = [...state.files, ...validFiles]; - updateUI(); - } - } - }; - - if (fileInput && dropZone) { - fileInput.addEventListener('change', (e) => handleFileSelect((e.target as HTMLInputElement).files)); - dropZone.addEventListener('dragover', (e) => { e.preventDefault(); dropZone.classList.add('bg-gray-700'); }); - dropZone.addEventListener('dragleave', (e) => { e.preventDefault(); dropZone.classList.remove('bg-gray-700'); }); - dropZone.addEventListener('drop', (e) => { e.preventDefault(); dropZone.classList.remove('bg-gray-700'); handleFileSelect(e.dataTransfer?.files ?? null); }); - fileInput.addEventListener('click', () => { fileInput.value = ''; }); + } } - if (addMoreBtn) addMoreBtn.addEventListener('click', () => fileInput.click()); - if (clearFilesBtn) clearFilesBtn.addEventListener('click', resetState); - if (processBtn) processBtn.addEventListener('click', convert); + }; + + if (fileInput && dropZone) { + fileInput.addEventListener('change', (e) => + handleFileSelect((e.target as HTMLInputElement).files) + ); + dropZone.addEventListener('dragover', (e) => { + e.preventDefault(); + dropZone.classList.add('bg-gray-700'); + }); + dropZone.addEventListener('dragleave', (e) => { + e.preventDefault(); + dropZone.classList.remove('bg-gray-700'); + }); + dropZone.addEventListener('drop', (e) => { + e.preventDefault(); + dropZone.classList.remove('bg-gray-700'); + handleFileSelect(e.dataTransfer?.files ?? null); + }); + fileInput.addEventListener('click', () => { + fileInput.value = ''; + }); + } + if (addMoreBtn) addMoreBtn.addEventListener('click', () => fileInput.click()); + if (clearFilesBtn) clearFilesBtn.addEventListener('click', resetState); + if (processBtn) processBtn.addEventListener('click', convert); }); diff --git a/src/js/logic/rasterize-pdf-page.ts b/src/js/logic/rasterize-pdf-page.ts index 83f081b..fc9960b 100644 --- a/src/js/logic/rasterize-pdf-page.ts +++ b/src/js/logic/rasterize-pdf-page.ts @@ -1,219 +1,262 @@ import { showLoader, hideLoader, showAlert } from '../ui.js'; -import { downloadFile, readFileAsArrayBuffer, formatBytes, getPDFDocument } from '../utils/helpers.js'; +import { + downloadFile, + readFileAsArrayBuffer, + formatBytes, + getPDFDocument, +} from '../utils/helpers.js'; import { state } from '../state.js'; import { createIcons, icons } from 'lucide'; -import { PyMuPDF } from '@bentopdf/pymupdf-wasm'; -import { getWasmBaseUrl } from '../config/wasm-cdn-config.js'; - -const pymupdf = new PyMuPDF(getWasmBaseUrl('pymupdf')); +import { isWasmAvailable, getWasmBaseUrl } from '../config/wasm-cdn-config.js'; +import { showWasmRequiredDialog } from '../utils/wasm-provider.js'; +import { loadPyMuPDF, isPyMuPDFAvailable } from '../utils/pymupdf-loader.js'; document.addEventListener('DOMContentLoaded', () => { - const fileInput = document.getElementById('file-input') as HTMLInputElement; - const dropZone = document.getElementById('drop-zone'); - const processBtn = document.getElementById('process-btn'); - const fileDisplayArea = document.getElementById('file-display-area'); - const rasterizeOptions = document.getElementById('rasterize-options'); - const fileControls = document.getElementById('file-controls'); - const addMoreBtn = document.getElementById('add-more-btn'); - const clearFilesBtn = document.getElementById('clear-files-btn'); - const backBtn = document.getElementById('back-to-tools'); + const fileInput = document.getElementById('file-input') as HTMLInputElement; + const dropZone = document.getElementById('drop-zone'); + const processBtn = document.getElementById('process-btn'); + const fileDisplayArea = document.getElementById('file-display-area'); + const rasterizeOptions = document.getElementById('rasterize-options'); + const fileControls = document.getElementById('file-controls'); + const addMoreBtn = document.getElementById('add-more-btn'); + const clearFilesBtn = document.getElementById('clear-files-btn'); + const backBtn = document.getElementById('back-to-tools'); - if (backBtn) { - backBtn.addEventListener('click', () => { - window.location.href = import.meta.env.BASE_URL; - }); - } + if (backBtn) { + backBtn.addEventListener('click', () => { + window.location.href = import.meta.env.BASE_URL; + }); + } - const updateUI = async () => { - if (!fileDisplayArea || !rasterizeOptions || !processBtn || !fileControls) return; + const updateUI = async () => { + if (!fileDisplayArea || !rasterizeOptions || !processBtn || !fileControls) + return; - if (state.files.length > 0) { - fileDisplayArea.innerHTML = ''; + if (state.files.length > 0) { + fileDisplayArea.innerHTML = ''; - for (let index = 0; index < state.files.length; index++) { - const file = state.files[index]; - const fileDiv = document.createElement('div'); - fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm'; + for (let index = 0; index < state.files.length; index++) { + const file = state.files[index]; + const fileDiv = document.createElement('div'); + fileDiv.className = + 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm'; - const infoContainer = document.createElement('div'); - infoContainer.className = 'flex flex-col overflow-hidden'; + const infoContainer = document.createElement('div'); + infoContainer.className = 'flex flex-col overflow-hidden'; - const nameSpan = document.createElement('div'); - nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1'; - nameSpan.textContent = file.name; + const nameSpan = document.createElement('div'); + nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1'; + nameSpan.textContent = file.name; - const metaSpan = document.createElement('div'); - metaSpan.className = 'text-xs text-gray-400'; - metaSpan.textContent = `${formatBytes(file.size)} • Loading pages...`; + const metaSpan = document.createElement('div'); + metaSpan.className = 'text-xs text-gray-400'; + metaSpan.textContent = `${formatBytes(file.size)} • Loading pages...`; - infoContainer.append(nameSpan, metaSpan); + infoContainer.append(nameSpan, metaSpan); - const removeBtn = document.createElement('button'); - removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0'; - removeBtn.innerHTML = ''; - removeBtn.onclick = () => { - state.files = state.files.filter((_, i) => i !== index); - updateUI(); - }; + const removeBtn = document.createElement('button'); + removeBtn.className = + 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0'; + removeBtn.innerHTML = ''; + removeBtn.onclick = () => { + state.files = state.files.filter((_, i) => i !== index); + updateUI(); + }; - fileDiv.append(infoContainer, removeBtn); - fileDisplayArea.appendChild(fileDiv); + fileDiv.append(infoContainer, removeBtn); + fileDisplayArea.appendChild(fileDiv); - try { - const arrayBuffer = await readFileAsArrayBuffer(file); - const pdfDoc = await getPDFDocument({ data: arrayBuffer }).promise; - metaSpan.textContent = `${formatBytes(file.size)} • ${pdfDoc.numPages} pages`; - } catch (error) { - console.error('Error loading PDF:', error); - metaSpan.textContent = `${formatBytes(file.size)} • Could not load page count`; - } - } - - createIcons({ icons }); - fileControls.classList.remove('hidden'); - rasterizeOptions.classList.remove('hidden'); - (processBtn as HTMLButtonElement).disabled = false; - } else { - fileDisplayArea.innerHTML = ''; - fileControls.classList.add('hidden'); - rasterizeOptions.classList.add('hidden'); - (processBtn as HTMLButtonElement).disabled = true; - } - }; - - const resetState = () => { - state.files = []; - state.pdfDoc = null; - updateUI(); - }; - - const rasterize = async () => { try { - if (state.files.length === 0) { - showAlert('No Files', 'Please select at least one PDF file.'); - return; - } - - showLoader('Loading engine...'); - await pymupdf.load(); - - // Get options from UI - const dpi = parseInt((document.getElementById('rasterize-dpi') as HTMLSelectElement).value) || 150; - const format = (document.getElementById('rasterize-format') as HTMLSelectElement).value as 'png' | 'jpeg'; - const grayscale = (document.getElementById('rasterize-grayscale') as HTMLInputElement).checked; - - const total = state.files.length; - let completed = 0; - let failed = 0; - - if (total === 1) { - const file = state.files[0]; - showLoader(`Rasterizing ${file.name}...`); - - const rasterizedBlob = await (pymupdf as any).rasterizePdf(file, { - dpi, - format, - grayscale, - quality: 95 - }); - - const outName = file.name.replace(/\.pdf$/i, '') + '_rasterized.pdf'; - downloadFile(rasterizedBlob, outName); - - hideLoader(); - showAlert('Rasterization Complete', `Successfully rasterized PDF at ${dpi} DPI.`, 'success', () => resetState()); - } else { - // Multiple files - create ZIP - const JSZip = (await import('jszip')).default; - const zip = new JSZip(); - - for (const file of state.files) { - try { - showLoader(`Rasterizing ${file.name} (${completed + 1}/${total})...`); - - const rasterizedBlob = await (pymupdf as any).rasterizePdf(file, { - dpi, - format, - grayscale, - quality: 95 - }); - - const outName = file.name.replace(/\.pdf$/i, '') + '_rasterized.pdf'; - zip.file(outName, rasterizedBlob); - - completed++; - } catch (error) { - console.error(`Failed to rasterize ${file.name}:`, error); - failed++; - } - } - - showLoader('Creating ZIP archive...'); - const zipBlob = await zip.generateAsync({ type: 'blob' }); - - downloadFile(zipBlob, 'rasterized-pdfs.zip'); - - hideLoader(); - - if (failed === 0) { - showAlert('Rasterization Complete', `Successfully rasterized ${completed} PDF(s) at ${dpi} DPI.`, 'success', () => resetState()); - } else { - showAlert('Rasterization Partial', `Rasterized ${completed} PDF(s), failed ${failed}.`, 'warning', () => resetState()); - } - } - } catch (e: any) { - hideLoader(); - showAlert('Error', `An error occurred during rasterization. Error: ${e.message}`); + const arrayBuffer = await readFileAsArrayBuffer(file); + const pdfDoc = await getPDFDocument({ data: arrayBuffer }).promise; + metaSpan.textContent = `${formatBytes(file.size)} • ${pdfDoc.numPages} pages`; + } catch (error) { + console.error('Error loading PDF:', error); + metaSpan.textContent = `${formatBytes(file.size)} • Could not load page count`; } - }; + } - const handleFileSelect = (files: FileList | null) => { - if (files && files.length > 0) { - const pdfFiles = Array.from(files).filter(f => f.type === 'application/pdf' || f.name.toLowerCase().endsWith('.pdf')); - if (pdfFiles.length > 0) { - state.files = [...state.files, ...pdfFiles]; - updateUI(); - } + createIcons({ icons }); + fileControls.classList.remove('hidden'); + rasterizeOptions.classList.remove('hidden'); + (processBtn as HTMLButtonElement).disabled = false; + } else { + fileDisplayArea.innerHTML = ''; + fileControls.classList.add('hidden'); + rasterizeOptions.classList.add('hidden'); + (processBtn as HTMLButtonElement).disabled = true; + } + }; + + const resetState = () => { + state.files = []; + state.pdfDoc = null; + updateUI(); + }; + + const rasterize = async () => { + try { + if (state.files.length === 0) { + showAlert('No Files', 'Please select at least one PDF file.'); + return; + } + + if (!isPyMuPDFAvailable()) { + showWasmRequiredDialog('pymupdf'); + return; + } + + showLoader('Loading engine...'); + const pymupdf = await loadPyMuPDF(); + + // Get options from UI + const dpi = + parseInt( + (document.getElementById('rasterize-dpi') as HTMLSelectElement).value + ) || 150; + const format = ( + document.getElementById('rasterize-format') as HTMLSelectElement + ).value as 'png' | 'jpeg'; + const grayscale = ( + document.getElementById('rasterize-grayscale') as HTMLInputElement + ).checked; + + const total = state.files.length; + let completed = 0; + let failed = 0; + + if (total === 1) { + const file = state.files[0]; + showLoader(`Rasterizing ${file.name}...`); + + const rasterizedBlob = await (pymupdf as any).rasterizePdf(file, { + dpi, + format, + grayscale, + quality: 95, + }); + + const outName = file.name.replace(/\.pdf$/i, '') + '_rasterized.pdf'; + downloadFile(rasterizedBlob, outName); + + hideLoader(); + showAlert( + 'Rasterization Complete', + `Successfully rasterized PDF at ${dpi} DPI.`, + 'success', + () => resetState() + ); + } else { + // Multiple files - create ZIP + const JSZip = (await import('jszip')).default; + const zip = new JSZip(); + + for (const file of state.files) { + try { + showLoader( + `Rasterizing ${file.name} (${completed + 1}/${total})...` + ); + + const rasterizedBlob = await (pymupdf as any).rasterizePdf(file, { + dpi, + format, + grayscale, + quality: 95, + }); + + const outName = + file.name.replace(/\.pdf$/i, '') + '_rasterized.pdf'; + zip.file(outName, rasterizedBlob); + + completed++; + } catch (error) { + console.error(`Failed to rasterize ${file.name}:`, error); + failed++; + } } - }; - if (fileInput && dropZone) { - fileInput.addEventListener('change', (e) => { - handleFileSelect((e.target as HTMLInputElement).files); - }); + showLoader('Creating ZIP archive...'); + const zipBlob = await zip.generateAsync({ type: 'blob' }); - dropZone.addEventListener('dragover', (e) => { - e.preventDefault(); - dropZone.classList.add('bg-gray-700'); - }); + downloadFile(zipBlob, 'rasterized-pdfs.zip'); - dropZone.addEventListener('dragleave', (e) => { - e.preventDefault(); - dropZone.classList.remove('bg-gray-700'); - }); + hideLoader(); - dropZone.addEventListener('drop', (e) => { - e.preventDefault(); - dropZone.classList.remove('bg-gray-700'); - handleFileSelect(e.dataTransfer?.files ?? null); - }); - - fileInput.addEventListener('click', () => { - fileInput.value = ''; - }); + if (failed === 0) { + showAlert( + 'Rasterization Complete', + `Successfully rasterized ${completed} PDF(s) at ${dpi} DPI.`, + 'success', + () => resetState() + ); + } else { + showAlert( + 'Rasterization Partial', + `Rasterized ${completed} PDF(s), failed ${failed}.`, + 'warning', + () => resetState() + ); + } + } + } catch (e: any) { + hideLoader(); + showAlert( + 'Error', + `An error occurred during rasterization. Error: ${e.message}` + ); } + }; - if (addMoreBtn) { - addMoreBtn.addEventListener('click', () => { - fileInput.click(); - }); + const handleFileSelect = (files: FileList | null) => { + if (files && files.length > 0) { + const pdfFiles = Array.from(files).filter( + (f) => + f.type === 'application/pdf' || f.name.toLowerCase().endsWith('.pdf') + ); + if (pdfFiles.length > 0) { + state.files = [...state.files, ...pdfFiles]; + updateUI(); + } } + }; - if (clearFilesBtn) { - clearFilesBtn.addEventListener('click', resetState); - } + if (fileInput && dropZone) { + fileInput.addEventListener('change', (e) => { + handleFileSelect((e.target as HTMLInputElement).files); + }); - if (processBtn) { - processBtn.addEventListener('click', rasterize); - } + dropZone.addEventListener('dragover', (e) => { + e.preventDefault(); + dropZone.classList.add('bg-gray-700'); + }); + + dropZone.addEventListener('dragleave', (e) => { + e.preventDefault(); + dropZone.classList.remove('bg-gray-700'); + }); + + dropZone.addEventListener('drop', (e) => { + e.preventDefault(); + dropZone.classList.remove('bg-gray-700'); + handleFileSelect(e.dataTransfer?.files ?? null); + }); + + fileInput.addEventListener('click', () => { + fileInput.value = ''; + }); + } + + if (addMoreBtn) { + addMoreBtn.addEventListener('click', () => { + fileInput.click(); + }); + } + + if (clearFilesBtn) { + clearFilesBtn.addEventListener('click', resetState); + } + + if (processBtn) { + processBtn.addEventListener('click', rasterize); + } }); diff --git a/src/js/logic/remove-blank-pages-page.ts b/src/js/logic/remove-blank-pages-page.ts index f320e63..dea1fa8 100644 --- a/src/js/logic/remove-blank-pages-page.ts +++ b/src/js/logic/remove-blank-pages-page.ts @@ -1,65 +1,79 @@ import { PDFDocument } from 'pdf-lib'; import * as pdfjsLib from 'pdfjs-dist'; import { createIcons, icons } from 'lucide'; +import { initPagePreview } from '../utils/page-preview.js'; -pdfjsLib.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).toString(); +pdfjsLib.GlobalWorkerOptions.workerSrc = new URL( + 'pdfjs-dist/build/pdf.worker.min.mjs', + import.meta.url +).toString(); // State const pageState: { - pdfDoc: PDFDocument | null; - file: File | null; - detectedBlankPages: number[]; - pageThumbnails: Map; + pdfDoc: PDFDocument | null; + file: File | null; + detectedBlankPages: number[]; + pageThumbnails: Map; } = { - pdfDoc: null, - file: null, - detectedBlankPages: [], - pageThumbnails: new Map() + pdfDoc: null, + file: null, + detectedBlankPages: [], + pageThumbnails: new Map(), }; function showLoader(msg = 'Processing...') { - document.getElementById('loader-modal')?.classList.remove('hidden'); - const txt = document.getElementById('loader-text'); - if (txt) txt.textContent = msg; + document.getElementById('loader-modal')?.classList.remove('hidden'); + const txt = document.getElementById('loader-text'); + if (txt) txt.textContent = msg; } -function hideLoader() { document.getElementById('loader-modal')?.classList.add('hidden'); } +function hideLoader() { + document.getElementById('loader-modal')?.classList.add('hidden'); +} -function showAlert(title: string, msg: string, type = 'error', cb?: () => void) { - const modal = document.getElementById('alert-modal'); - const t = document.getElementById('alert-title'); - const m = document.getElementById('alert-message'); - if (t) t.textContent = title; - if (m) m.textContent = msg; - modal?.classList.remove('hidden'); - const okBtn = document.getElementById('alert-ok'); - if (okBtn) { - const newBtn = okBtn.cloneNode(true) as HTMLElement; - okBtn.replaceWith(newBtn); - newBtn.addEventListener('click', () => { - modal?.classList.add('hidden'); - if (cb) cb(); - }); - } +function showAlert( + title: string, + msg: string, + type = 'error', + cb?: () => void +) { + const modal = document.getElementById('alert-modal'); + const t = document.getElementById('alert-title'); + const m = document.getElementById('alert-message'); + if (t) t.textContent = title; + if (m) m.textContent = msg; + modal?.classList.remove('hidden'); + const okBtn = document.getElementById('alert-ok'); + if (okBtn) { + const newBtn = okBtn.cloneNode(true) as HTMLElement; + okBtn.replaceWith(newBtn); + newBtn.addEventListener('click', () => { + modal?.classList.add('hidden'); + if (cb) cb(); + }); + } } function downloadFile(blob: Blob, filename: string) { - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; a.download = filename; a.click(); - URL.revokeObjectURL(url); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); } function updateFileDisplay() { - const area = document.getElementById('file-display-area'); - if (!area || !pageState.file || !pageState.pdfDoc) return; + const area = document.getElementById('file-display-area'); + if (!area || !pageState.file || !pageState.pdfDoc) return; - const fileSize = pageState.file.size < 1024 * 1024 - ? `${(pageState.file.size / 1024).toFixed(1)} KB` - : `${(pageState.file.size / 1024 / 1024).toFixed(2)} MB`; - const pageCount = pageState.pdfDoc.getPageCount(); + const fileSize = + pageState.file.size < 1024 * 1024 + ? `${(pageState.file.size / 1024).toFixed(1)} KB` + : `${(pageState.file.size / 1024 / 1024).toFixed(2)} MB`; + const pageCount = pageState.pdfDoc.getPageCount(); - area.innerHTML = ` + area.innerHTML = `
@@ -72,257 +86,285 @@ function updateFileDisplay() {
`; - createIcons({ icons }); - document.getElementById('remove-file')?.addEventListener('click', resetState); + createIcons({ icons }); + document.getElementById('remove-file')?.addEventListener('click', resetState); } function resetState() { - pageState.pdfDoc = null; - pageState.file = null; - pageState.detectedBlankPages = []; - pageState.pageThumbnails.forEach(url => URL.revokeObjectURL(url)); - pageState.pageThumbnails.clear(); + pageState.pdfDoc = null; + pageState.file = null; + pageState.detectedBlankPages = []; + pageState.pageThumbnails.forEach((url) => URL.revokeObjectURL(url)); + pageState.pageThumbnails.clear(); - const area = document.getElementById('file-display-area'); - if (area) area.innerHTML = ''; - document.getElementById('options-panel')?.classList.add('hidden'); - document.getElementById('preview-panel')?.classList.add('hidden'); - const inp = document.getElementById('file-input') as HTMLInputElement; - if (inp) inp.value = ''; + const area = document.getElementById('file-display-area'); + if (area) area.innerHTML = ''; + document.getElementById('options-panel')?.classList.add('hidden'); + document.getElementById('preview-panel')?.classList.add('hidden'); + const inp = document.getElementById('file-input') as HTMLInputElement; + if (inp) inp.value = ''; + const slider = document.getElementById( + 'sensitivity-slider' + ) as HTMLInputElement; + if (slider) slider.value = '80'; + const sliderLabel = document.getElementById('sensitivity-value'); + if (sliderLabel) sliderLabel.textContent = '80'; } async function handleFileUpload(file: File) { - if (!file || file.type !== 'application/pdf') { - showAlert('Error', 'Please upload a valid PDF file.'); - return; - } - showLoader('Loading PDF...'); - try { - const buf = await file.arrayBuffer(); - pageState.pdfDoc = await PDFDocument.load(buf); - pageState.file = file; - pageState.detectedBlankPages = []; - updateFileDisplay(); - document.getElementById('options-panel')?.classList.remove('hidden'); - document.getElementById('preview-panel')?.classList.add('hidden'); - } catch (e) { - console.error(e); - showAlert('Error', 'Failed to load PDF file.'); - } finally { - hideLoader(); - } + if (!file || file.type !== 'application/pdf') { + showAlert('Error', 'Please upload a valid PDF file.'); + return; + } + showLoader('Loading PDF...'); + try { + const buf = await file.arrayBuffer(); + pageState.pdfDoc = await PDFDocument.load(buf); + pageState.file = file; + pageState.detectedBlankPages = []; + updateFileDisplay(); + document.getElementById('options-panel')?.classList.remove('hidden'); + document.getElementById('preview-panel')?.classList.add('hidden'); + } catch (e) { + console.error(e); + showAlert('Error', 'Failed to load PDF file.'); + } finally { + hideLoader(); + } } -async function isPageBlank(page: any, threshold = 250): Promise { - const viewport = page.getViewport({ scale: 0.5 }); // Lower scale for faster processing - const canvas = document.createElement('canvas'); - const ctx = canvas.getContext('2d'); - if (!ctx) return false; +async function isPageBlank( + page: any, + maxNonWhitePercent = 0.5 +): Promise { + const viewport = page.getViewport({ scale: 0.5 }); + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + if (!ctx) return false; - canvas.width = viewport.width; - canvas.height = viewport.height; + canvas.width = viewport.width; + canvas.height = viewport.height; - await page.render({ canvasContext: ctx, viewport }).promise; + await page.render({ canvasContext: ctx, viewport }).promise; - const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); - const data = imageData.data; + const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); + const data = imageData.data; + const totalPixels = data.length / 4; - let totalBrightness = 0; - for (let i = 0; i < data.length; i += 4) { - const r = data[i], g = data[i + 1], b = data[i + 2]; - totalBrightness += (r + g + b) / 3; - } + let nonWhitePixels = 0; + for (let i = 0; i < data.length; i += 4) { + const brightness = (data[i] + data[i + 1] + data[i + 2]) / 3; + if (brightness < 240) nonWhitePixels++; + } - const avgBrightness = totalBrightness / (data.length / 4); - return avgBrightness > threshold; + const nonWhitePercent = (nonWhitePixels / totalPixels) * 100; + return nonWhitePercent <= maxNonWhitePercent; } async function generateThumbnail(page: any): Promise { - const viewport = page.getViewport({ scale: 0.3 }); - const canvas = document.createElement('canvas'); - const ctx = canvas.getContext('2d'); - if (!ctx) return ''; + const viewport = page.getViewport({ scale: 1 }); + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + if (!ctx) return ''; - canvas.width = viewport.width; - canvas.height = viewport.height; + canvas.width = viewport.width; + canvas.height = viewport.height; - await page.render({ canvasContext: ctx, viewport }).promise; - return canvas.toDataURL('image/jpeg', 0.7); + await page.render({ canvasContext: ctx, viewport }).promise; + return canvas.toDataURL('image/jpeg', 0.7); } async function detectBlankPages() { - if (!pageState.pdfDoc || !pageState.file) return showAlert('Error', 'Please upload a PDF first.'); + if (!pageState.pdfDoc || !pageState.file) + return showAlert('Error', 'Please upload a PDF first.'); - const sensitivitySlider = document.getElementById('sensitivity-slider') as HTMLInputElement; - const sensitivityPercent = parseInt(sensitivitySlider?.value || '80'); - const threshold = Math.round(255 - (sensitivityPercent * 2.55)); + const sensitivitySlider = document.getElementById( + 'sensitivity-slider' + ) as HTMLInputElement; + const sensitivityPercent = parseInt(sensitivitySlider?.value || '80'); + const maxNonWhitePercent = 5 - (sensitivityPercent / 100) * 4.9; - showLoader('Detecting blank pages...'); - try { - const pdfData = await pageState.file.arrayBuffer(); - const pdfDoc = await pdfjsLib.getDocument({ data: pdfData }).promise; - const totalPages = pdfDoc.numPages; + showLoader('Detecting blank pages...'); + try { + const pdfData = await pageState.file.arrayBuffer(); + const pdfDoc = await pdfjsLib.getDocument({ data: pdfData }).promise; + const totalPages = pdfDoc.numPages; - pageState.detectedBlankPages = []; - pageState.pageThumbnails.forEach(url => URL.revokeObjectURL(url)); - pageState.pageThumbnails.clear(); + pageState.detectedBlankPages = []; + pageState.pageThumbnails.forEach((url) => URL.revokeObjectURL(url)); + pageState.pageThumbnails.clear(); - for (let i = 1; i <= totalPages; i++) { - const page = await pdfDoc.getPage(i); - if (await isPageBlank(page, threshold)) { - pageState.detectedBlankPages.push(i - 1); // 0-indexed - const thumbnail = await generateThumbnail(page); - pageState.pageThumbnails.set(i - 1, thumbnail); - } - } - - if (pageState.detectedBlankPages.length === 0) { - showAlert('Info', 'No blank pages detected in this PDF.'); - hideLoader(); - return; - } - - // Show preview panel - updatePreviewPanel(); - document.getElementById('preview-panel')?.classList.remove('hidden'); - hideLoader(); - } catch (e) { - console.error(e); - showAlert('Error', 'Could not detect blank pages.'); - hideLoader(); + for (let i = 1; i <= totalPages; i++) { + const page = await pdfDoc.getPage(i); + if (await isPageBlank(page, maxNonWhitePercent)) { + pageState.detectedBlankPages.push(i - 1); // 0-indexed + const thumbnail = await generateThumbnail(page); + pageState.pageThumbnails.set(i - 1, thumbnail); + } } + + if (pageState.detectedBlankPages.length === 0) { + showAlert('Info', 'No blank pages detected in this PDF.'); + hideLoader(); + return; + } + + // Show preview panel + updatePreviewPanel(); + document.getElementById('preview-panel')?.classList.remove('hidden'); + + const previewContainer = document.getElementById('blank-pages-preview'); + if (previewContainer) initPagePreview(previewContainer, pdfDoc); + + hideLoader(); + } catch (e) { + console.error(e); + showAlert('Error', 'Could not detect blank pages.'); + hideLoader(); + } } function updatePreviewPanel() { - const previewInfo = document.getElementById('preview-info'); - const previewContainer = document.getElementById('blank-pages-preview'); + const previewInfo = document.getElementById('preview-info'); + const previewContainer = document.getElementById('blank-pages-preview'); - if (!previewInfo || !previewContainer) return; + if (!previewInfo || !previewContainer) return; - previewInfo.textContent = `Found ${pageState.detectedBlankPages.length} blank page(s). Click on a page to deselect it.`; - previewContainer.innerHTML = ''; + previewInfo.textContent = `Found ${pageState.detectedBlankPages.length} blank page(s). Click on a page to deselect it.`; + previewContainer.innerHTML = ''; - pageState.detectedBlankPages.forEach((pageIndex) => { - const thumbnail = pageState.pageThumbnails.get(pageIndex) || ''; - const div = document.createElement('div'); - div.className = 'relative cursor-pointer group'; - div.dataset.pageIndex = String(pageIndex); - div.dataset.selected = 'true'; + pageState.detectedBlankPages.forEach((pageIndex) => { + const thumbnail = pageState.pageThumbnails.get(pageIndex) || ''; + const div = document.createElement('div'); + div.className = + 'relative cursor-pointer flex flex-col items-center gap-1 p-2 border-2 border-red-500 rounded-lg bg-gray-700 transition-colors group'; + div.dataset.pageIndex = String(pageIndex); + div.dataset.selected = 'true'; - div.innerHTML = ` -
- Page ${pageIndex + 1} -
- Page ${pageIndex + 1} + div.innerHTML = ` +
+ Page ${pageIndex + 1} +
+ ${pageIndex + 1}
-
+
`; - div.addEventListener('click', () => togglePageSelection(div, pageIndex)); - previewContainer.appendChild(div); - }); + div.addEventListener('click', () => togglePageSelection(div, pageIndex)); + previewContainer.appendChild(div); + }); - createIcons({ icons }); + createIcons({ icons }); } function togglePageSelection(div: HTMLElement, pageIndex: number) { - const isSelected = div.dataset.selected === 'true'; - const border = div.querySelector('.border-2') as HTMLElement; - const checkMark = div.querySelector('.check-mark') as HTMLElement; + const isSelected = div.dataset.selected === 'true'; + const checkMark = div.querySelector('.check-mark') as HTMLElement; - if (isSelected) { - div.dataset.selected = 'false'; - border?.classList.remove('border-red-500'); - border?.classList.add('border-gray-500', 'opacity-50'); - checkMark?.classList.add('hidden'); - } else { - div.dataset.selected = 'true'; - border?.classList.add('border-red-500'); - border?.classList.remove('border-gray-500', 'opacity-50'); - checkMark?.classList.remove('hidden'); - } + if (isSelected) { + div.dataset.selected = 'false'; + div.classList.remove('border-red-500'); + div.classList.add('border-gray-600', 'opacity-50'); + checkMark?.classList.add('hidden'); + } else { + div.dataset.selected = 'true'; + div.classList.add('border-red-500'); + div.classList.remove('border-gray-600', 'opacity-50'); + checkMark?.classList.remove('hidden'); + } } async function processRemoveBlankPages() { - if (!pageState.pdfDoc || !pageState.file) return showAlert('Error', 'Please upload a PDF first.'); + if (!pageState.pdfDoc || !pageState.file) + return showAlert('Error', 'Please upload a PDF first.'); - // Get selected pages to remove - const previewContainer = document.getElementById('blank-pages-preview'); - const selectedPages: number[] = []; - previewContainer?.querySelectorAll('[data-selected="true"]').forEach(el => { - const pageIndex = parseInt((el as HTMLElement).dataset.pageIndex || '-1'); - if (pageIndex >= 0) selectedPages.push(pageIndex); - }); + // Get selected pages to remove + const previewContainer = document.getElementById('blank-pages-preview'); + const selectedPages: number[] = []; + previewContainer?.querySelectorAll('[data-selected="true"]').forEach((el) => { + const pageIndex = parseInt((el as HTMLElement).dataset.pageIndex || '-1'); + if (pageIndex >= 0) selectedPages.push(pageIndex); + }); - if (selectedPages.length === 0) { - showAlert('Info', 'No pages selected for removal.'); - return; + if (selectedPages.length === 0) { + showAlert('Info', 'No pages selected for removal.'); + return; + } + + showLoader(`Removing ${selectedPages.length} blank page(s)...`); + try { + const newPdf = await PDFDocument.create(); + const pages = pageState.pdfDoc.getPages(); + + for (let i = 0; i < pages.length; i++) { + if (!selectedPages.includes(i)) { + const [copiedPage] = await newPdf.copyPages(pageState.pdfDoc, [i]); + newPdf.addPage(copiedPage); + } } - showLoader(`Removing ${selectedPages.length} blank page(s)...`); - try { - const newPdf = await PDFDocument.create(); - const pages = pageState.pdfDoc.getPages(); - - for (let i = 0; i < pages.length; i++) { - if (!selectedPages.includes(i)) { - const [copiedPage] = await newPdf.copyPages(pageState.pdfDoc, [i]); - newPdf.addPage(copiedPage); - } - } - - const newPdfBytes = await newPdf.save(); - downloadFile(new Blob([new Uint8Array(newPdfBytes)], { type: 'application/pdf' }), 'blank-pages-removed.pdf'); - showAlert('Success', `Removed ${selectedPages.length} blank page(s) successfully!`, 'success', resetState); - } catch (e) { - console.error(e); - showAlert('Error', 'Could not remove blank pages.'); - } finally { - hideLoader(); - } + const newPdfBytes = await newPdf.save(); + downloadFile( + new Blob([new Uint8Array(newPdfBytes)], { type: 'application/pdf' }), + 'blank-pages-removed.pdf' + ); + showAlert( + 'Success', + `Removed ${selectedPages.length} blank page(s) successfully!`, + 'success', + resetState + ); + } catch (e) { + console.error(e); + showAlert('Error', 'Could not remove blank pages.'); + } finally { + hideLoader(); + } } document.addEventListener('DOMContentLoaded', () => { - const fileInput = document.getElementById('file-input') as HTMLInputElement; - const dropZone = document.getElementById('drop-zone'); - const detectBtn = document.getElementById('detect-btn'); - const processBtn = document.getElementById('process-btn'); - const sensitivitySlider = document.getElementById('sensitivity-slider') as HTMLInputElement; - const sensitivityValue = document.getElementById('sensitivity-value'); + const fileInput = document.getElementById('file-input') as HTMLInputElement; + const dropZone = document.getElementById('drop-zone'); + const detectBtn = document.getElementById('detect-btn'); + const processBtn = document.getElementById('process-btn'); + const sensitivitySlider = document.getElementById( + 'sensitivity-slider' + ) as HTMLInputElement; + const sensitivityValue = document.getElementById('sensitivity-value'); - sensitivitySlider?.addEventListener('input', (e) => { - const value = (e.target as HTMLInputElement).value; - if (sensitivityValue) sensitivityValue.textContent = value; - }); + sensitivitySlider?.addEventListener('input', (e) => { + const value = (e.target as HTMLInputElement).value; + if (sensitivityValue) sensitivityValue.textContent = value; + }); - fileInput?.addEventListener('change', (e) => { - const f = (e.target as HTMLInputElement).files?.[0]; - if (f) handleFileUpload(f); - }); + fileInput?.addEventListener('change', (e) => { + const f = (e.target as HTMLInputElement).files?.[0]; + if (f) handleFileUpload(f); + }); - dropZone?.addEventListener('dragover', (e) => { - e.preventDefault(); - dropZone.classList.add('border-indigo-500'); - }); + dropZone?.addEventListener('dragover', (e) => { + e.preventDefault(); + dropZone.classList.add('border-indigo-500'); + }); - dropZone?.addEventListener('dragleave', () => { - dropZone.classList.remove('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 f = e.dataTransfer?.files[0]; - if (f) handleFileUpload(f); - }); + dropZone?.addEventListener('drop', (e) => { + e.preventDefault(); + dropZone.classList.remove('border-indigo-500'); + const f = e.dataTransfer?.files[0]; + if (f) handleFileUpload(f); + }); - detectBtn?.addEventListener('click', detectBlankPages); - processBtn?.addEventListener('click', processRemoveBlankPages); + detectBtn?.addEventListener('click', detectBlankPages); + processBtn?.addEventListener('click', processRemoveBlankPages); - document.getElementById('back-to-tools')?.addEventListener('click', () => { - window.location.href = '../../index.html'; - }); + document.getElementById('back-to-tools')?.addEventListener('click', () => { + window.location.href = '../../index.html'; + }); }); diff --git a/src/js/logic/rotate-pdf-page.ts b/src/js/logic/rotate-pdf-page.ts index 5681fe9..1c347b7 100644 --- a/src/js/logic/rotate-pdf-page.ts +++ b/src/js/logic/rotate-pdf-page.ts @@ -1,355 +1,359 @@ import { showLoader, hideLoader, showAlert } from '../ui.js'; import { downloadFile, formatBytes, getPDFDocument } from '../utils/helpers.js'; import { createIcons, icons } from 'lucide'; -import { PDFDocument as PDFLibDocument, degrees } from 'pdf-lib'; -import { renderPagesProgressively, cleanupLazyRendering } from '../utils/render-utils.js'; +import { PDFDocument as PDFLibDocument } from 'pdf-lib'; +import { + renderPagesProgressively, + cleanupLazyRendering, +} from '../utils/render-utils.js'; +import { rotatePdfPages } from '../utils/pdf-operations.js'; import * as pdfjsLib from 'pdfjs-dist'; -pdfjsLib.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).toString(); +pdfjsLib.GlobalWorkerOptions.workerSrc = new URL( + 'pdfjs-dist/build/pdf.worker.min.mjs', + import.meta.url +).toString(); interface RotateState { - file: File | null; - pdfDoc: PDFLibDocument | null; - pdfJsDoc: pdfjsLib.PDFDocumentProxy | null; - rotations: number[]; + file: File | null; + pdfDoc: PDFLibDocument | null; + pdfJsDoc: pdfjsLib.PDFDocumentProxy | null; + rotations: number[]; } const pageState: RotateState = { - file: null, - pdfDoc: null, - pdfJsDoc: null, - rotations: [], + file: null, + pdfDoc: null, + pdfJsDoc: null, + rotations: [], }; function resetState() { - cleanupLazyRendering(); - pageState.file = null; - pageState.pdfDoc = null; - pageState.pdfJsDoc = null; - pageState.rotations = []; + cleanupLazyRendering(); + pageState.file = null; + pageState.pdfDoc = null; + pageState.pdfJsDoc = null; + pageState.rotations = []; - const fileDisplayArea = document.getElementById('file-display-area'); - if (fileDisplayArea) fileDisplayArea.innerHTML = ''; + const fileDisplayArea = document.getElementById('file-display-area'); + if (fileDisplayArea) fileDisplayArea.innerHTML = ''; - const toolOptions = document.getElementById('tool-options'); - if (toolOptions) toolOptions.classList.add('hidden'); + const toolOptions = document.getElementById('tool-options'); + if (toolOptions) toolOptions.classList.add('hidden'); - const pageThumbnails = document.getElementById('page-thumbnails'); - if (pageThumbnails) pageThumbnails.innerHTML = ''; + const pageThumbnails = document.getElementById('page-thumbnails'); + if (pageThumbnails) pageThumbnails.innerHTML = ''; - const fileInput = document.getElementById('file-input') as HTMLInputElement; - if (fileInput) fileInput.value = ''; + const fileInput = document.getElementById('file-input') as HTMLInputElement; + if (fileInput) fileInput.value = ''; } function updateAllRotationDisplays() { - for (let i = 0; i < pageState.rotations.length; i++) { - const container = document.querySelector(`[data-page-index="${i}"]`); - if (container) { - const wrapper = container.querySelector('.thumbnail-wrapper') as HTMLElement; - if (wrapper) wrapper.style.transform = `rotate(${pageState.rotations[i]}deg)`; - } + for (let i = 0; i < pageState.rotations.length; i++) { + const container = document.querySelector(`[data-page-index="${i}"]`); + if (container) { + const wrapper = container.querySelector( + '.thumbnail-wrapper' + ) as HTMLElement; + if (wrapper) + wrapper.style.transform = `rotate(${pageState.rotations[i]}deg)`; } + } } -function createPageWrapper(canvas: HTMLCanvasElement, pageNumber: number): HTMLElement { - const pageIndex = pageNumber - 1; +function createPageWrapper( + canvas: HTMLCanvasElement, + pageNumber: number +): HTMLElement { + const pageIndex = pageNumber - 1; - const container = document.createElement('div'); - container.className = 'page-thumbnail relative bg-gray-700 rounded-lg overflow-hidden'; - container.dataset.pageIndex = pageIndex.toString(); - container.dataset.pageNumber = pageNumber.toString(); + const container = document.createElement('div'); + container.className = + 'page-thumbnail relative bg-gray-700 rounded-lg overflow-hidden'; + container.dataset.pageIndex = pageIndex.toString(); + container.dataset.pageNumber = pageNumber.toString(); - const canvasWrapper = document.createElement('div'); - canvasWrapper.className = 'thumbnail-wrapper flex items-center justify-center p-2 h-36'; - canvasWrapper.style.transition = 'transform 0.3s ease'; - // Apply initial rotation if it exists - const initialRotation = pageState.rotations[pageIndex] || 0; - canvasWrapper.style.transform = `rotate(${initialRotation}deg)`; + const canvasWrapper = document.createElement('div'); + canvasWrapper.className = + 'thumbnail-wrapper flex items-center justify-center p-2 h-36'; + canvasWrapper.style.transition = 'transform 0.3s ease'; + // Apply initial rotation if it exists + const initialRotation = pageState.rotations[pageIndex] || 0; + canvasWrapper.style.transform = `rotate(${initialRotation}deg)`; - canvas.className = 'max-w-full max-h-full object-contain'; - canvasWrapper.appendChild(canvas); + canvas.className = 'max-w-full max-h-full object-contain'; + canvasWrapper.appendChild(canvas); - const pageLabel = document.createElement('div'); - pageLabel.className = 'absolute top-1 left-1 bg-black bg-opacity-60 text-white text-xs px-2 py-1 rounded'; - pageLabel.textContent = `${pageNumber}`; + const pageLabel = document.createElement('div'); + pageLabel.className = + 'absolute top-1 left-1 bg-black bg-opacity-60 text-white text-xs px-2 py-1 rounded'; + pageLabel.textContent = `${pageNumber}`; - container.appendChild(canvasWrapper); - container.appendChild(pageLabel); + container.appendChild(canvasWrapper); + container.appendChild(pageLabel); - // Per-page rotation controls - Left and Right buttons only - const controls = document.createElement('div'); - controls.className = 'flex items-center justify-center gap-2 p-2 bg-gray-800'; + // Per-page rotation controls - Left and Right buttons only + const controls = document.createElement('div'); + controls.className = 'flex items-center justify-center gap-2 p-2 bg-gray-800'; - const rotateLeftBtn = document.createElement('button'); - rotateLeftBtn.className = 'flex items-center gap-1 px-3 py-1.5 bg-gray-700 hover:bg-gray-600 text-white rounded border border-gray-600 text-xs'; - rotateLeftBtn.innerHTML = ''; - rotateLeftBtn.onclick = function (e) { - e.stopPropagation(); - pageState.rotations[pageIndex] = pageState.rotations[pageIndex] - 90; - const wrapper = container.querySelector('.thumbnail-wrapper') as HTMLElement; - if (wrapper) wrapper.style.transform = `rotate(${pageState.rotations[pageIndex]}deg)`; - }; + const rotateLeftBtn = document.createElement('button'); + rotateLeftBtn.className = + 'flex items-center gap-1 px-3 py-1.5 bg-gray-700 hover:bg-gray-600 text-white rounded border border-gray-600 text-xs cursor-pointer'; + rotateLeftBtn.innerHTML = ''; + rotateLeftBtn.addEventListener('click', function (e) { + e.stopPropagation(); + e.preventDefault(); + pageState.rotations[pageIndex] = pageState.rotations[pageIndex] - 90; + const wrapper = container.querySelector( + '.thumbnail-wrapper' + ) as HTMLElement; + if (wrapper) + wrapper.style.transform = `rotate(${pageState.rotations[pageIndex]}deg)`; + }); - const rotateRightBtn = document.createElement('button'); - rotateRightBtn.className = 'flex items-center gap-1 px-3 py-1.5 bg-gray-700 hover:bg-gray-600 text-white rounded border border-gray-600 text-xs'; - rotateRightBtn.innerHTML = ''; - rotateRightBtn.onclick = function (e) { - e.stopPropagation(); - pageState.rotations[pageIndex] = pageState.rotations[pageIndex] + 90; - const wrapper = container.querySelector('.thumbnail-wrapper') as HTMLElement; - if (wrapper) wrapper.style.transform = `rotate(${pageState.rotations[pageIndex]}deg)`; - }; + const rotateRightBtn = document.createElement('button'); + rotateRightBtn.className = + 'flex items-center gap-1 px-3 py-1.5 bg-gray-700 hover:bg-gray-600 text-white rounded border border-gray-600 text-xs cursor-pointer'; + rotateRightBtn.innerHTML = ''; + rotateRightBtn.addEventListener('click', function (e) { + e.stopPropagation(); + e.preventDefault(); + pageState.rotations[pageIndex] = pageState.rotations[pageIndex] + 90; + const wrapper = container.querySelector( + '.thumbnail-wrapper' + ) as HTMLElement; + if (wrapper) + wrapper.style.transform = `rotate(${pageState.rotations[pageIndex]}deg)`; + }); - controls.append(rotateLeftBtn, rotateRightBtn); - container.appendChild(controls); + controls.append(rotateLeftBtn, rotateRightBtn); + container.appendChild(controls); - // Re-create icons for the new element - setTimeout(function () { - createIcons({ icons }); - }, 0); + // Re-create icons scoped to this container only + setTimeout(function () { + createIcons({ icons, nameAttr: 'data-lucide', attrs: {} }); + }, 0); - return container; + return container; } async function renderThumbnails() { - const pageThumbnails = document.getElementById('page-thumbnails'); - if (!pageThumbnails || !pageState.pdfJsDoc) return; + const pageThumbnails = document.getElementById('page-thumbnails'); + if (!pageThumbnails || !pageState.pdfJsDoc) return; - pageThumbnails.innerHTML = ''; + pageThumbnails.innerHTML = ''; - await renderPagesProgressively( - pageState.pdfJsDoc, - pageThumbnails, - createPageWrapper, - { - batchSize: 8, - useLazyLoading: true, - lazyLoadMargin: '200px', - eagerLoadBatches: 2, - onBatchComplete: function () { - createIcons({ icons }); - } - } - ); + await renderPagesProgressively( + pageState.pdfJsDoc, + pageThumbnails, + createPageWrapper, + { + batchSize: 8, + useLazyLoading: true, + lazyLoadMargin: '200px', + eagerLoadBatches: 2, + onBatchComplete: function () { + createIcons({ icons }); + }, + } + ); - createIcons({ icons }); + createIcons({ icons }); } async function updateUI() { - const fileDisplayArea = document.getElementById('file-display-area'); - const toolOptions = document.getElementById('tool-options'); + const fileDisplayArea = document.getElementById('file-display-area'); + const toolOptions = document.getElementById('tool-options'); - if (!fileDisplayArea) return; + if (!fileDisplayArea) return; - fileDisplayArea.innerHTML = ''; + fileDisplayArea.innerHTML = ''; - if (pageState.file) { - const fileDiv = document.createElement('div'); - fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm'; + if (pageState.file) { + const fileDiv = document.createElement('div'); + fileDiv.className = + 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm'; - const infoContainer = document.createElement('div'); - infoContainer.className = 'flex flex-col overflow-hidden'; + const infoContainer = document.createElement('div'); + infoContainer.className = 'flex flex-col overflow-hidden'; - const nameSpan = document.createElement('div'); - nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1'; - nameSpan.textContent = pageState.file.name; + const nameSpan = document.createElement('div'); + nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1'; + nameSpan.textContent = pageState.file.name; - const metaSpan = document.createElement('div'); - metaSpan.className = 'text-xs text-gray-400'; - metaSpan.textContent = `${formatBytes(pageState.file.size)} • Loading...`; + const metaSpan = document.createElement('div'); + metaSpan.className = 'text-xs text-gray-400'; + metaSpan.textContent = `${formatBytes(pageState.file.size)} • Loading...`; - infoContainer.append(nameSpan, metaSpan); + infoContainer.append(nameSpan, metaSpan); - const removeBtn = document.createElement('button'); - removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0'; - removeBtn.innerHTML = ''; - removeBtn.onclick = function () { - resetState(); - }; + const removeBtn = document.createElement('button'); + removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0'; + removeBtn.innerHTML = ''; + removeBtn.onclick = function () { + resetState(); + }; - fileDiv.append(infoContainer, removeBtn); - fileDisplayArea.appendChild(fileDiv); - createIcons({ icons }); + fileDiv.append(infoContainer, removeBtn); + fileDisplayArea.appendChild(fileDiv); + createIcons({ icons }); - try { - showLoader('Loading PDF...'); - const arrayBuffer = await pageState.file.arrayBuffer(); + try { + showLoader('Loading PDF...'); + const arrayBuffer = await pageState.file.arrayBuffer(); - pageState.pdfDoc = await PDFLibDocument.load(arrayBuffer.slice(0), { - ignoreEncryption: true, - throwOnInvalidObject: false - }); + pageState.pdfDoc = await PDFLibDocument.load(arrayBuffer.slice(0), { + ignoreEncryption: true, + throwOnInvalidObject: false, + }); - pageState.pdfJsDoc = await getPDFDocument({ data: arrayBuffer.slice(0) }).promise; + pageState.pdfJsDoc = await getPDFDocument({ data: arrayBuffer.slice(0) }) + .promise; - const pageCount = pageState.pdfDoc.getPageCount(); - pageState.rotations = new Array(pageCount).fill(0); + const pageCount = pageState.pdfDoc.getPageCount(); + pageState.rotations = new Array(pageCount).fill(0); - metaSpan.textContent = `${formatBytes(pageState.file.size)} • ${pageCount} pages`; + metaSpan.textContent = `${formatBytes(pageState.file.size)} • ${pageCount} pages`; - await renderThumbnails(); - hideLoader(); + await renderThumbnails(); + hideLoader(); - if (toolOptions) toolOptions.classList.remove('hidden'); - } catch (error) { - console.error('Error loading PDF:', error); - hideLoader(); - showAlert('Error', 'Failed to load PDF file.'); - resetState(); - } - } else { - if (toolOptions) toolOptions.classList.add('hidden'); + if (toolOptions) toolOptions.classList.remove('hidden'); + } catch (error) { + console.error('Error loading PDF:', error); + hideLoader(); + showAlert('Error', 'Failed to load PDF file.'); + resetState(); } + } else { + if (toolOptions) toolOptions.classList.add('hidden'); + } } async function applyRotations() { - if (!pageState.pdfDoc || !pageState.file) { - showAlert('Error', 'Please upload a PDF first.'); - return; - } + if (!pageState.pdfDoc || !pageState.file) { + showAlert('Error', 'Please upload a PDF first.'); + return; + } - showLoader('Applying rotations...'); + showLoader('Applying rotations...'); - try { - const pageCount = pageState.pdfDoc.getPageCount(); - const newPdfDoc = await PDFLibDocument.create(); + try { + const pdfBytes = await pageState.pdfDoc.save(); + const rotatedPdfBytes = await rotatePdfPages( + new Uint8Array(pdfBytes), + pageState.rotations + ); + const originalName = pageState.file.name.replace(/\.pdf$/i, ''); - for (let i = 0; i < pageCount; i++) { - const rotation = pageState.rotations[i] || 0; - const originalPage = pageState.pdfDoc.getPage(i); - const currentRotation = originalPage.getRotation().angle; - const totalRotation = currentRotation + rotation; + downloadFile( + new Blob([rotatedPdfBytes as unknown as BlobPart], { + type: 'application/pdf', + }), + `${originalName}_rotated.pdf` + ); - console.log(`Page ${i}: rotation=${rotation}, currentRotation=${currentRotation}, totalRotation=${totalRotation}, applying=${-totalRotation}`); - - if (totalRotation % 90 === 0) { - const [copiedPage] = await newPdfDoc.copyPages(pageState.pdfDoc, [i]); - copiedPage.setRotation(degrees(totalRotation)); - newPdfDoc.addPage(copiedPage); - } else { - const embeddedPage = await newPdfDoc.embedPage(originalPage); - const { width, height } = embeddedPage.scale(1); - - const angleRad = (totalRotation * Math.PI) / 180; - const absCos = Math.abs(Math.cos(angleRad)); - const absSin = Math.abs(Math.sin(angleRad)); - - const newWidth = width * absCos + height * absSin; - const newHeight = width * absSin + height * absCos; - - const newPage = newPdfDoc.addPage([newWidth, newHeight]); - - const x = newWidth / 2 - (width / 2 * Math.cos(angleRad) - height / 2 * Math.sin(angleRad)); - const y = newHeight / 2 - (width / 2 * Math.sin(angleRad) + height / 2 * Math.cos(angleRad)); - - newPage.drawPage(embeddedPage, { - x, - y, - width, - height, - rotate: degrees(totalRotation), - }); - } - } - - const rotatedPdfBytes = await newPdfDoc.save(); - const originalName = pageState.file.name.replace(/\.pdf$/i, ''); - - downloadFile( - new Blob([new Uint8Array(rotatedPdfBytes)], { type: 'application/pdf' }), - `${originalName}_rotated.pdf` - ); - - showAlert('Success', 'Rotations applied successfully!', 'success', function () { - resetState(); - }); - } catch (e) { - console.error(e); - showAlert('Error', 'Could not apply rotations.'); - } finally { - hideLoader(); - } + showAlert( + 'Success', + 'Rotations applied successfully!', + 'success', + function () { + resetState(); + } + ); + } catch (e) { + console.error(e); + showAlert('Error', 'Could not apply rotations.'); + } finally { + hideLoader(); + } } function handleFileSelect(files: FileList | null) { - if (files && files.length > 0) { - const file = files[0]; - if (file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf')) { - pageState.file = file; - updateUI(); - } + if (files && files.length > 0) { + const file = files[0]; + if ( + file.type === 'application/pdf' || + file.name.toLowerCase().endsWith('.pdf') + ) { + pageState.file = file; + updateUI(); } + } } document.addEventListener('DOMContentLoaded', function () { - const fileInput = document.getElementById('file-input') as HTMLInputElement; - const dropZone = document.getElementById('drop-zone'); - const processBtn = document.getElementById('process-btn'); - const backBtn = document.getElementById('back-to-tools'); - const rotateAllLeft = document.getElementById('rotate-all-left'); - const rotateAllRight = document.getElementById('rotate-all-right'); + const fileInput = document.getElementById('file-input') as HTMLInputElement; + const dropZone = document.getElementById('drop-zone'); + const processBtn = document.getElementById('process-btn'); + const backBtn = document.getElementById('back-to-tools'); + const rotateAllLeft = document.getElementById('rotate-all-left'); + const rotateAllRight = document.getElementById('rotate-all-right'); - if (backBtn) { - backBtn.addEventListener('click', function () { - window.location.href = import.meta.env.BASE_URL; + if (backBtn) { + backBtn.addEventListener('click', function () { + window.location.href = import.meta.env.BASE_URL; + }); + } + + if (rotateAllLeft) { + rotateAllLeft.addEventListener('click', function () { + for (let i = 0; i < pageState.rotations.length; i++) { + pageState.rotations[i] = pageState.rotations[i] - 90; + } + updateAllRotationDisplays(); + }); + } + + if (rotateAllRight) { + rotateAllRight.addEventListener('click', function () { + for (let i = 0; i < pageState.rotations.length; i++) { + pageState.rotations[i] = pageState.rotations[i] + 90; + } + updateAllRotationDisplays(); + }); + } + + if (fileInput && dropZone) { + fileInput.addEventListener('change', function (e) { + handleFileSelect((e.target as HTMLInputElement).files); + }); + + dropZone.addEventListener('dragover', function (e) { + e.preventDefault(); + dropZone.classList.add('bg-gray-700'); + }); + + dropZone.addEventListener('dragleave', function (e) { + e.preventDefault(); + dropZone.classList.remove('bg-gray-700'); + }); + + dropZone.addEventListener('drop', function (e) { + e.preventDefault(); + dropZone.classList.remove('bg-gray-700'); + const files = e.dataTransfer?.files; + if (files && files.length > 0) { + const pdfFiles = Array.from(files).filter(function (f) { + return ( + f.type === 'application/pdf' || + f.name.toLowerCase().endsWith('.pdf') + ); }); - } + if (pdfFiles.length > 0) { + const dataTransfer = new DataTransfer(); + dataTransfer.items.add(pdfFiles[0]); + handleFileSelect(dataTransfer.files); + } + } + }); - if (rotateAllLeft) { - rotateAllLeft.addEventListener('click', function () { - for (let i = 0; i < pageState.rotations.length; i++) { - pageState.rotations[i] = pageState.rotations[i] - 90; - } - updateAllRotationDisplays(); - }); - } + fileInput.addEventListener('click', function () { + fileInput.value = ''; + }); + } - if (rotateAllRight) { - rotateAllRight.addEventListener('click', function () { - for (let i = 0; i < pageState.rotations.length; i++) { - pageState.rotations[i] = pageState.rotations[i] + 90; - } - updateAllRotationDisplays(); - }); - } - - if (fileInput && dropZone) { - fileInput.addEventListener('change', function (e) { - handleFileSelect((e.target as HTMLInputElement).files); - }); - - dropZone.addEventListener('dragover', function (e) { - e.preventDefault(); - dropZone.classList.add('bg-gray-700'); - }); - - dropZone.addEventListener('dragleave', function (e) { - e.preventDefault(); - dropZone.classList.remove('bg-gray-700'); - }); - - dropZone.addEventListener('drop', function (e) { - e.preventDefault(); - dropZone.classList.remove('bg-gray-700'); - const files = e.dataTransfer?.files; - if (files && files.length > 0) { - const pdfFiles = Array.from(files).filter(function (f) { - return f.type === 'application/pdf' || f.name.toLowerCase().endsWith('.pdf'); - }); - if (pdfFiles.length > 0) { - const dataTransfer = new DataTransfer(); - dataTransfer.items.add(pdfFiles[0]); - handleFileSelect(dataTransfer.files); - } - } - }); - - fileInput.addEventListener('click', function () { - fileInput.value = ''; - }); - } - - if (processBtn) { - processBtn.addEventListener('click', applyRotations); - } + if (processBtn) { + processBtn.addEventListener('click', applyRotations); + } }); diff --git a/src/js/logic/sanitize-pdf-page.ts b/src/js/logic/sanitize-pdf-page.ts index 170a129..76ea679 100644 --- a/src/js/logic/sanitize-pdf-page.ts +++ b/src/js/logic/sanitize-pdf-page.ts @@ -1,743 +1,199 @@ import { showAlert } from '../ui.js'; import { downloadFile, formatBytes } from '../utils/helpers.js'; -import { PDFDocument, PDFName } from 'pdf-lib'; import { icons, createIcons } from 'lucide'; import { SanitizePdfState } from '@/types'; +import { sanitizePdf } from '../utils/sanitize.js'; const pageState: SanitizePdfState = { - file: null, - pdfDoc: null, + file: null, + pdfDoc: null, }; -function removeMetadataFromDoc(pdfDoc: PDFDocument) { - const infoDict = (pdfDoc as any).getInfoDict(); - const allKeys = infoDict.keys(); - allKeys.forEach((key: any) => { - infoDict.delete(key); - }); - - pdfDoc.setTitle(''); - pdfDoc.setAuthor(''); - pdfDoc.setSubject(''); - pdfDoc.setKeywords([]); - pdfDoc.setCreator(''); - pdfDoc.setProducer(''); - - try { - const catalogDict = (pdfDoc.catalog as any).dict; - if (catalogDict.has(PDFName.of('Metadata'))) { - catalogDict.delete(PDFName.of('Metadata')); - } - } catch (e: any) { - console.warn('Could not remove XMP metadata:', e.message); - } - - try { - const context = pdfDoc.context; - if ((context as any).trailerInfo) { - delete (context as any).trailerInfo.ID; - } - } catch (e: any) { - console.warn('Could not remove document IDs:', e.message); - } - - try { - const catalogDict = (pdfDoc.catalog as any).dict; - if (catalogDict.has(PDFName.of('PieceInfo'))) { - catalogDict.delete(PDFName.of('PieceInfo')); - } - } catch (e: any) { - console.warn('Could not remove PieceInfo:', e.message); - } -} - -function removeAnnotationsFromDoc(pdfDoc: PDFDocument) { - const pages = pdfDoc.getPages(); - for (const page of pages) { - try { - page.node.delete(PDFName.of('Annots')); - } catch (e: any) { - console.warn('Could not remove annotations from page:', e.message); - } - } -} - -function flattenFormsInDoc(pdfDoc: PDFDocument) { - const form = pdfDoc.getForm(); - form.flatten(); -} - function resetState() { - pageState.file = null; - pageState.pdfDoc = null; + pageState.file = null; + pageState.pdfDoc = null; - const fileDisplayArea = document.getElementById('file-display-area'); - if (fileDisplayArea) fileDisplayArea.innerHTML = ''; + const fileDisplayArea = document.getElementById('file-display-area'); + if (fileDisplayArea) fileDisplayArea.innerHTML = ''; - const toolOptions = document.getElementById('tool-options'); - if (toolOptions) toolOptions.classList.add('hidden'); + const toolOptions = document.getElementById('tool-options'); + if (toolOptions) toolOptions.classList.add('hidden'); - const fileInput = document.getElementById('file-input') as HTMLInputElement; - if (fileInput) fileInput.value = ''; + const fileInput = document.getElementById('file-input') as HTMLInputElement; + if (fileInput) fileInput.value = ''; } async function updateUI() { - const fileDisplayArea = document.getElementById('file-display-area'); - const toolOptions = document.getElementById('tool-options'); + const fileDisplayArea = document.getElementById('file-display-area'); + const toolOptions = document.getElementById('tool-options'); - if (!fileDisplayArea) return; + if (!fileDisplayArea) return; - fileDisplayArea.innerHTML = ''; + fileDisplayArea.innerHTML = ''; - if (pageState.file) { - const fileDiv = document.createElement('div'); - fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm'; + if (pageState.file) { + const fileDiv = document.createElement('div'); + fileDiv.className = + 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm'; - const infoContainer = document.createElement('div'); - infoContainer.className = 'flex flex-col overflow-hidden'; + const infoContainer = document.createElement('div'); + infoContainer.className = 'flex flex-col overflow-hidden'; - const nameSpan = document.createElement('div'); - nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1'; - nameSpan.textContent = pageState.file.name; + const nameSpan = document.createElement('div'); + nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1'; + nameSpan.textContent = pageState.file.name; - const metaSpan = document.createElement('div'); - metaSpan.className = 'text-xs text-gray-400'; - metaSpan.textContent = formatBytes(pageState.file.size); + const metaSpan = document.createElement('div'); + metaSpan.className = 'text-xs text-gray-400'; + metaSpan.textContent = formatBytes(pageState.file.size); - infoContainer.append(nameSpan, metaSpan); + infoContainer.append(nameSpan, metaSpan); - const removeBtn = document.createElement('button'); - removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0'; - removeBtn.innerHTML = ''; - removeBtn.onclick = function () { - resetState(); - }; + const removeBtn = document.createElement('button'); + removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0'; + removeBtn.innerHTML = ''; + removeBtn.onclick = function () { + resetState(); + }; - fileDiv.append(infoContainer, removeBtn); - fileDisplayArea.appendChild(fileDiv); - createIcons({ icons }); + fileDiv.append(infoContainer, removeBtn); + fileDisplayArea.appendChild(fileDiv); + createIcons({ icons }); - if (toolOptions) toolOptions.classList.remove('hidden'); - } else { - if (toolOptions) toolOptions.classList.add('hidden'); - } + if (toolOptions) toolOptions.classList.remove('hidden'); + } else { + if (toolOptions) toolOptions.classList.add('hidden'); + } } async function handleFileSelect(files: FileList | null) { - if (files && files.length > 0) { - const file = files[0]; - if (file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf')) { - pageState.file = file; - - // Load PDF for sanitization - try { - const arrayBuffer = await file.arrayBuffer(); - pageState.pdfDoc = await PDFDocument.load(arrayBuffer); - updateUI(); - } catch (e) { - console.error('Error loading PDF:', e); - showAlert('Error', 'Failed to load PDF file.'); - } - } + if (files && files.length > 0) { + const file = files[0]; + if ( + file.type === 'application/pdf' || + file.name.toLowerCase().endsWith('.pdf') + ) { + pageState.file = file; + updateUI(); } + } } -async function sanitizePdf() { - if (!pageState.pdfDoc) { - showAlert('Error', 'No PDF document loaded.'); - return; +async function runSanitize() { + if (!pageState.file) { + showAlert('Error', 'No PDF document loaded.'); + return; + } + + const loaderModal = document.getElementById('loader-modal'); + const loaderText = document.getElementById('loader-text'); + if (loaderModal) loaderModal.classList.remove('hidden'); + if (loaderText) loaderText.textContent = 'Sanitizing PDF...'; + + try { + const options = { + flattenForms: ( + document.getElementById('flatten-forms') as HTMLInputElement + ).checked, + removeMetadata: ( + document.getElementById('remove-metadata') as HTMLInputElement + ).checked, + removeAnnotations: ( + document.getElementById('remove-annotations') as HTMLInputElement + ).checked, + removeJavascript: ( + document.getElementById('remove-javascript') as HTMLInputElement + ).checked, + removeEmbeddedFiles: ( + document.getElementById('remove-embedded-files') as HTMLInputElement + ).checked, + removeLayers: ( + document.getElementById('remove-layers') as HTMLInputElement + ).checked, + removeLinks: (document.getElementById('remove-links') as HTMLInputElement) + .checked, + removeStructureTree: ( + document.getElementById('remove-structure-tree') as HTMLInputElement + ).checked, + removeMarkInfo: ( + document.getElementById('remove-markinfo') as HTMLInputElement + ).checked, + removeFonts: (document.getElementById('remove-fonts') as HTMLInputElement) + .checked, + }; + + const hasAnyOption = Object.values(options).some(Boolean); + if (!hasAnyOption) { + showAlert( + 'No Changes', + 'No items were selected for removal or none were found in the PDF.' + ); + if (loaderModal) loaderModal.classList.add('hidden'); + return; } - const loaderModal = document.getElementById('loader-modal'); - const loaderText = document.getElementById('loader-text'); - if (loaderModal) loaderModal.classList.remove('hidden'); - if (loaderText) loaderText.textContent = 'Sanitizing PDF...'; - - try { - const pdfDoc = pageState.pdfDoc; - - const shouldFlattenForms = (document.getElementById('flatten-forms') as HTMLInputElement).checked; - const shouldRemoveMetadata = (document.getElementById('remove-metadata') as HTMLInputElement).checked; - const shouldRemoveAnnotations = (document.getElementById('remove-annotations') as HTMLInputElement).checked; - const shouldRemoveJavascript = (document.getElementById('remove-javascript') as HTMLInputElement).checked; - const shouldRemoveEmbeddedFiles = (document.getElementById('remove-embedded-files') as HTMLInputElement).checked; - const shouldRemoveLayers = (document.getElementById('remove-layers') as HTMLInputElement).checked; - const shouldRemoveLinks = (document.getElementById('remove-links') as HTMLInputElement).checked; - const shouldRemoveStructureTree = (document.getElementById('remove-structure-tree') as HTMLInputElement).checked; - const shouldRemoveMarkInfo = (document.getElementById('remove-markinfo') as HTMLInputElement).checked; - const shouldRemoveFonts = (document.getElementById('remove-fonts') as HTMLInputElement).checked; - - let changesMade = false; - - if (shouldFlattenForms) { - try { - flattenFormsInDoc(pdfDoc); - changesMade = true; - } catch (e: any) { - console.warn(`Could not flatten forms: ${e.message}`); - try { - const catalogDict = (pdfDoc.catalog as any).dict; - if (catalogDict.has(PDFName.of('AcroForm'))) { - catalogDict.delete(PDFName.of('AcroForm')); - changesMade = true; - } - } catch (removeError: any) { - console.warn('Could not remove AcroForm:', removeError.message); - } - } - } - - if (shouldRemoveMetadata) { - removeMetadataFromDoc(pdfDoc); - changesMade = true; - } - - if (shouldRemoveAnnotations) { - removeAnnotationsFromDoc(pdfDoc); - changesMade = true; - } - - if (shouldRemoveJavascript) { - try { - if ((pdfDoc as any).javaScripts && (pdfDoc as any).javaScripts.length > 0) { - (pdfDoc as any).javaScripts = []; - changesMade = true; - } - - const catalogDict = (pdfDoc.catalog as any).dict; - - const namesRef = catalogDict.get(PDFName.of('Names')); - if (namesRef) { - try { - const namesDict = pdfDoc.context.lookup(namesRef) as any; - if (namesDict.has(PDFName.of('JavaScript'))) { - namesDict.delete(PDFName.of('JavaScript')); - changesMade = true; - } - } catch (e: any) { - console.warn('Could not access Names/JavaScript:', e.message); - } - } - - if (catalogDict.has(PDFName.of('OpenAction'))) { - catalogDict.delete(PDFName.of('OpenAction')); - changesMade = true; - } - - if (catalogDict.has(PDFName.of('AA'))) { - catalogDict.delete(PDFName.of('AA')); - changesMade = true; - } - - const pages = pdfDoc.getPages(); - for (const page of pages) { - try { - const pageDict = page.node; - - if (pageDict.has(PDFName.of('AA'))) { - pageDict.delete(PDFName.of('AA')); - changesMade = true; - } - - const annotRefs = pageDict.Annots()?.asArray() || []; - for (const annotRef of annotRefs) { - try { - const annot = pdfDoc.context.lookup(annotRef) as any; - - if (annot.has(PDFName.of('A'))) { - const actionRef = annot.get(PDFName.of('A')); - try { - const actionDict = pdfDoc.context.lookup(actionRef) as any; - const actionType = actionDict - .get(PDFName.of('S')) - ?.toString() - .substring(1); - - if (actionType === 'JavaScript') { - annot.delete(PDFName.of('A')); - changesMade = true; - } - } catch (e: any) { - console.warn('Could not read action:', e.message); - } - } - - if (annot.has(PDFName.of('AA'))) { - annot.delete(PDFName.of('AA')); - changesMade = true; - } - } catch (e: any) { - console.warn('Could not process annotation for JS:', e.message); - } - } - } catch (e: any) { - console.warn('Could not remove page actions:', e.message); - } - } - - try { - const acroFormRef = catalogDict.get(PDFName.of('AcroForm')); - if (acroFormRef) { - const acroFormDict = pdfDoc.context.lookup(acroFormRef) as any; - const fieldsRef = acroFormDict.get(PDFName.of('Fields')); - - if (fieldsRef) { - const fieldsArray = pdfDoc.context.lookup(fieldsRef) as any; - const fields = fieldsArray.asArray(); - - for (const fieldRef of fields) { - try { - const field = pdfDoc.context.lookup(fieldRef) as any; - - if (field.has(PDFName.of('A'))) { - field.delete(PDFName.of('A')); - changesMade = true; - } - - if (field.has(PDFName.of('AA'))) { - field.delete(PDFName.of('AA')); - changesMade = true; - } - } catch (e: any) { - console.warn('Could not process field for JS:', e.message); - } - } - } - } - } catch (e: any) { - console.warn('Could not process form fields for JS:', e.message); - } - } catch (e: any) { - console.warn(`Could not remove JavaScript: ${e.message}`); - } - } - - if (shouldRemoveEmbeddedFiles) { - try { - const catalogDict = (pdfDoc.catalog as any).dict; - - const namesRef = catalogDict.get(PDFName.of('Names')); - if (namesRef) { - try { - const namesDict = pdfDoc.context.lookup(namesRef) as any; - if (namesDict.has(PDFName.of('EmbeddedFiles'))) { - namesDict.delete(PDFName.of('EmbeddedFiles')); - changesMade = true; - } - } catch (e: any) { - console.warn('Could not access Names/EmbeddedFiles:', e.message); - } - } - - if (catalogDict.has(PDFName.of('EmbeddedFiles'))) { - catalogDict.delete(PDFName.of('EmbeddedFiles')); - changesMade = true; - } - - const pages = pdfDoc.getPages(); - for (const page of pages) { - try { - const annotRefs = page.node.Annots()?.asArray() || []; - const annotsToKeep = []; - - for (const ref of annotRefs) { - try { - const annot = pdfDoc.context.lookup(ref) as any; - const subtype = annot - .get(PDFName.of('Subtype')) - ?.toString() - .substring(1); - - if (subtype !== 'FileAttachment') { - annotsToKeep.push(ref); - } else { - changesMade = true; - } - } catch (e) { - annotsToKeep.push(ref); - } - } - - if (annotsToKeep.length !== annotRefs.length) { - if (annotsToKeep.length > 0) { - const newAnnotsArray = pdfDoc.context.obj(annotsToKeep); - page.node.set(PDFName.of('Annots'), newAnnotsArray); - } else { - page.node.delete(PDFName.of('Annots')); - } - } - } catch (pageError: any) { - console.warn( - `Could not process page for attachments: ${pageError.message}` - ); - } - } - - if ((pdfDoc as any).embeddedFiles && (pdfDoc as any).embeddedFiles.length > 0) { - (pdfDoc as any).embeddedFiles = []; - changesMade = true; - } - - if (catalogDict.has(PDFName.of('Collection'))) { - catalogDict.delete(PDFName.of('Collection')); - changesMade = true; - } - } catch (e: any) { - console.warn(`Could not remove embedded files: ${e.message}`); - } - } - - if (shouldRemoveLayers) { - try { - const catalogDict = (pdfDoc.catalog as any).dict; - - if (catalogDict.has(PDFName.of('OCProperties'))) { - catalogDict.delete(PDFName.of('OCProperties')); - changesMade = true; - } - - const pages = pdfDoc.getPages(); - for (const page of pages) { - try { - const pageDict = page.node; - - if (pageDict.has(PDFName.of('OCProperties'))) { - pageDict.delete(PDFName.of('OCProperties')); - changesMade = true; - } - - const resourcesRef = pageDict.get(PDFName.of('Resources')); - if (resourcesRef) { - try { - const resourcesDict = pdfDoc.context.lookup(resourcesRef) as any; - if (resourcesDict.has(PDFName.of('Properties'))) { - resourcesDict.delete(PDFName.of('Properties')); - changesMade = true; - } - } catch (e: any) { - console.warn('Could not access Resources:', e.message); - } - } - } catch (e: any) { - console.warn('Could not remove page layers:', e.message); - } - } - } catch (e: any) { - console.warn(`Could not remove layers: ${e.message}`); - } - } - - if (shouldRemoveLinks) { - try { - const pages = pdfDoc.getPages(); - - for (let pageIndex = 0; pageIndex < pages.length; pageIndex++) { - try { - const page = pages[pageIndex]; - const pageDict = page.node; - - const annotsRef = pageDict.get(PDFName.of('Annots')); - if (!annotsRef) continue; - - const annotsArray = pdfDoc.context.lookup(annotsRef) as any; - const annotRefs = annotsArray.asArray(); - - if (annotRefs.length === 0) continue; - - const annotsToKeep = []; - let linksRemoved = 0; - - for (const ref of annotRefs) { - try { - const annot = pdfDoc.context.lookup(ref) as any; - const subtype = annot - .get(PDFName.of('Subtype')) - ?.toString() - .substring(1); - - let isLink = false; - - if (subtype === 'Link') { - isLink = true; - linksRemoved++; - } else { - const actionRef = annot.get(PDFName.of('A')); - if (actionRef) { - try { - const actionDict = pdfDoc.context.lookup(actionRef) as any; - const actionType = actionDict - .get(PDFName.of('S')) - ?.toString() - .substring(1); - - if ( - actionType === 'URI' || - actionType === 'Launch' || - actionType === 'GoTo' || - actionType === 'GoToR' - ) { - isLink = true; - linksRemoved++; - } - } catch (e: any) { - console.warn('Could not read action:', e.message); - } - } - - const dest = annot.get(PDFName.of('Dest')); - if (dest && !isLink) { - isLink = true; - linksRemoved++; - } - } - - if (!isLink) { - annotsToKeep.push(ref); - } - } catch (e: any) { - console.warn('Could not process annotation:', e.message); - annotsToKeep.push(ref); - } - } - - if (linksRemoved > 0) { - if (annotsToKeep.length > 0) { - const newAnnotsArray = pdfDoc.context.obj(annotsToKeep); - pageDict.set(PDFName.of('Annots'), newAnnotsArray); - } else { - pageDict.delete(PDFName.of('Annots')); - } - changesMade = true; - } - } catch (pageError: any) { - console.warn( - `Could not process page ${pageIndex + 1} for links: ${pageError.message}` - ); - } - } - - try { - const catalogDict = (pdfDoc.catalog as any).dict; - const namesRef = catalogDict.get(PDFName.of('Names')); - if (namesRef) { - try { - const namesDict = pdfDoc.context.lookup(namesRef) as any; - if (namesDict.has(PDFName.of('Dests'))) { - namesDict.delete(PDFName.of('Dests')); - changesMade = true; - } - } catch (e: any) { - console.warn('Could not access Names/Dests:', e.message); - } - } - - if (catalogDict.has(PDFName.of('Dests'))) { - catalogDict.delete(PDFName.of('Dests')); - changesMade = true; - } - } catch (e: any) { - console.warn('Could not remove named destinations:', e.message); - } - } catch (e: any) { - console.warn(`Could not remove links: ${e.message}`); - } - } - - if (shouldRemoveStructureTree) { - try { - const catalogDict = (pdfDoc.catalog as any).dict; - - if (catalogDict.has(PDFName.of('StructTreeRoot'))) { - catalogDict.delete(PDFName.of('StructTreeRoot')); - changesMade = true; - } - - const pages = pdfDoc.getPages(); - for (const page of pages) { - try { - const pageDict = page.node; - if (pageDict.has(PDFName.of('StructParents'))) { - pageDict.delete(PDFName.of('StructParents')); - changesMade = true; - } - } catch (e: any) { - console.warn('Could not remove page StructParents:', e.message); - } - } - - if (catalogDict.has(PDFName.of('ParentTree'))) { - catalogDict.delete(PDFName.of('ParentTree')); - changesMade = true; - } - } catch (e: any) { - console.warn(`Could not remove structure tree: ${e.message}`); - } - } - - if (shouldRemoveMarkInfo) { - try { - const catalogDict = (pdfDoc.catalog as any).dict; - - if (catalogDict.has(PDFName.of('MarkInfo'))) { - catalogDict.delete(PDFName.of('MarkInfo')); - changesMade = true; - } - - if (catalogDict.has(PDFName.of('Marked'))) { - catalogDict.delete(PDFName.of('Marked')); - changesMade = true; - } - } catch (e: any) { - console.warn(`Could not remove MarkInfo: ${e.message}`); - } - } - - if (shouldRemoveFonts) { - try { - const pages = pdfDoc.getPages(); - - for (let pageIndex = 0; pageIndex < pages.length; pageIndex++) { - try { - const page = pages[pageIndex]; - const pageDict = page.node; - const resourcesRef = pageDict.get(PDFName.of('Resources')); - - if (resourcesRef) { - try { - const resourcesDict = pdfDoc.context.lookup(resourcesRef) as any; - - if (resourcesDict.has(PDFName.of('Font'))) { - const fontRef = resourcesDict.get(PDFName.of('Font')); - - try { - const fontDict = pdfDoc.context.lookup(fontRef) as any; - const fontKeys = fontDict.keys(); - - for (const fontKey of fontKeys) { - try { - const specificFontRef = fontDict.get(fontKey); - const specificFont = - pdfDoc.context.lookup(specificFontRef) as any; - - if (specificFont.has(PDFName.of('FontDescriptor'))) { - const descriptorRef = specificFont.get( - PDFName.of('FontDescriptor') - ); - const descriptor = - pdfDoc.context.lookup(descriptorRef) as any; - - const fontFileKeys = [ - 'FontFile', - 'FontFile2', - 'FontFile3', - ]; - for (const key of fontFileKeys) { - if (descriptor.has(PDFName.of(key))) { - descriptor.delete(PDFName.of(key)); - changesMade = true; - } - } - } - } catch (e: any) { - console.warn( - `Could not process font ${fontKey}:`, - e.message - ); - } - } - } catch (e: any) { - console.warn( - 'Could not access font dictionary:', - e.message - ); - } - } - } catch (e: any) { - console.warn( - 'Could not access Resources for fonts:', - e.message - ); - } - } - } catch (e: any) { - console.warn( - `Could not remove fonts from page ${pageIndex + 1}:`, - e.message - ); - } - } - - if ((pdfDoc as any).fonts && (pdfDoc as any).fonts.length > 0) { - (pdfDoc as any).fonts = []; - changesMade = true; - } - } catch (e: any) { - console.warn(`Could not remove fonts: ${e.message}`); - } - } - - if (!changesMade) { - showAlert( - 'No Changes', - 'No items were selected for removal or none were found in the PDF.' - ); - if (loaderModal) loaderModal.classList.add('hidden'); - return; - } - - const sanitizedPdfBytes = await pdfDoc.save(); - downloadFile( - new Blob([sanitizedPdfBytes as BlobPart], { type: 'application/pdf' }), - 'sanitized.pdf' - ); - showAlert('Success', 'PDF has been sanitized and downloaded.', 'success', () => { resetState(); }); - } catch (e: any) { - console.error('Sanitization Error:', e); - showAlert('Error', `An error occurred during sanitization: ${e.message}`); - } finally { - if (loaderModal) loaderModal.classList.add('hidden'); - } + const arrayBuffer = await pageState.file.arrayBuffer(); + const result = await sanitizePdf(new Uint8Array(arrayBuffer), options); + + downloadFile( + new Blob([new Uint8Array(result.bytes)], { type: 'application/pdf' }), + 'sanitized.pdf' + ); + showAlert( + 'Success', + 'PDF has been sanitized and downloaded.', + 'success', + () => { + resetState(); + } + ); + } catch (e: any) { + console.error('Sanitization Error:', e); + showAlert('Error', `An error occurred during sanitization: ${e.message}`); + } finally { + if (loaderModal) loaderModal.classList.add('hidden'); + } } document.addEventListener('DOMContentLoaded', function () { - const fileInput = document.getElementById('file-input') as HTMLInputElement; - const dropZone = document.getElementById('drop-zone'); - const processBtn = document.getElementById('process-btn'); - const backBtn = document.getElementById('back-to-tools'); + const fileInput = document.getElementById('file-input') as HTMLInputElement; + const dropZone = document.getElementById('drop-zone'); + const processBtn = document.getElementById('process-btn'); + const backBtn = document.getElementById('back-to-tools'); - if (backBtn) { - backBtn.addEventListener('click', function () { - window.location.href = import.meta.env.BASE_URL; - }); - } + if (backBtn) { + backBtn.addEventListener('click', function () { + window.location.href = import.meta.env.BASE_URL; + }); + } - if (fileInput && dropZone) { - fileInput.addEventListener('change', function (e) { - handleFileSelect((e.target as HTMLInputElement).files); - }); + if (fileInput && dropZone) { + fileInput.addEventListener('change', function (e) { + handleFileSelect((e.target as HTMLInputElement).files); + }); - dropZone.addEventListener('dragover', function (e) { - e.preventDefault(); - dropZone.classList.add('bg-gray-700'); - }); + dropZone.addEventListener('dragover', function (e) { + e.preventDefault(); + dropZone.classList.add('bg-gray-700'); + }); - dropZone.addEventListener('dragleave', function (e) { - e.preventDefault(); - dropZone.classList.remove('bg-gray-700'); - }); + dropZone.addEventListener('dragleave', function (e) { + e.preventDefault(); + dropZone.classList.remove('bg-gray-700'); + }); - dropZone.addEventListener('drop', function (e) { - e.preventDefault(); - dropZone.classList.remove('bg-gray-700'); - handleFileSelect(e.dataTransfer?.files); - }); + dropZone.addEventListener('drop', function (e) { + e.preventDefault(); + dropZone.classList.remove('bg-gray-700'); + handleFileSelect(e.dataTransfer?.files); + }); - fileInput.addEventListener('click', function () { - fileInput.value = ''; - }); - } + fileInput.addEventListener('click', function () { + fileInput.value = ''; + }); + } - if (processBtn) { - processBtn.addEventListener('click', sanitizePdf); - } + if (processBtn) { + processBtn.addEventListener('click', runSanitize); + } }); diff --git a/src/js/logic/scanner-effect-page.ts b/src/js/logic/scanner-effect-page.ts new file mode 100644 index 0000000..a93bb8c --- /dev/null +++ b/src/js/logic/scanner-effect-page.ts @@ -0,0 +1,451 @@ +import { showLoader, hideLoader, showAlert } from '../ui.js'; +import { + downloadFile, + formatBytes, + readFileAsArrayBuffer, + getPDFDocument, +} from '../utils/helpers.js'; +import { createIcons, icons } from 'lucide'; +import { PDFDocument } from 'pdf-lib'; +import { applyScannerEffect } from '../utils/image-effects.js'; +import * as pdfjsLib from 'pdfjs-dist'; +import type { ScanSettings } from '../types/scanner-effect-type.js'; + +pdfjsLib.GlobalWorkerOptions.workerSrc = new URL( + 'pdfjs-dist/build/pdf.worker.min.mjs', + import.meta.url +).toString(); + +let files: File[] = []; +let cachedBaselineData: ImageData | null = null; +let cachedBaselineWidth = 0; +let cachedBaselineHeight = 0; +let pdfjsDoc: pdfjsLib.PDFDocumentProxy | null = null; + +function getSettings(): ScanSettings { + return { + grayscale: + (document.getElementById('setting-grayscale') as HTMLInputElement) + ?.checked ?? false, + border: + (document.getElementById('setting-border') as HTMLInputElement) + ?.checked ?? false, + rotate: parseFloat( + (document.getElementById('setting-rotate') as HTMLInputElement)?.value ?? + '0' + ), + rotateVariance: parseFloat( + (document.getElementById('setting-rotate-variance') as HTMLInputElement) + ?.value ?? '0' + ), + brightness: parseInt( + (document.getElementById('setting-brightness') as HTMLInputElement) + ?.value ?? '0' + ), + contrast: parseInt( + (document.getElementById('setting-contrast') as HTMLInputElement) + ?.value ?? '0' + ), + blur: parseFloat( + (document.getElementById('setting-blur') as HTMLInputElement)?.value ?? + '0' + ), + noise: parseInt( + (document.getElementById('setting-noise') as HTMLInputElement)?.value ?? + '10' + ), + yellowish: parseInt( + (document.getElementById('setting-yellowish') as HTMLInputElement) + ?.value ?? '0' + ), + resolution: parseInt( + (document.getElementById('setting-resolution') as HTMLInputElement) + ?.value ?? '150' + ), + }; +} + +const applyEffects = applyScannerEffect; + +function updatePreview(): void { + if (!cachedBaselineData) return; + + const previewCanvas = document.getElementById( + 'preview-canvas' + ) as HTMLCanvasElement; + if (!previewCanvas) return; + + const settings = getSettings(); + const baselineCopy = new ImageData( + new Uint8ClampedArray(cachedBaselineData.data), + cachedBaselineWidth, + cachedBaselineHeight + ); + + applyEffects(baselineCopy, previewCanvas, settings, settings.rotate); +} + +async function renderPreview(): Promise { + if (!pdfjsDoc) return; + + const page = await pdfjsDoc.getPage(1); + const viewport = page.getViewport({ scale: 1.0 }); + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d')!; + canvas.width = viewport.width; + canvas.height = viewport.height; + + await page.render({ canvasContext: ctx, viewport, canvas }).promise; + + cachedBaselineData = ctx.getImageData(0, 0, canvas.width, canvas.height); + cachedBaselineWidth = canvas.width; + cachedBaselineHeight = canvas.height; + + updatePreview(); +} + +const updateUI = () => { + const fileDisplayArea = document.getElementById('file-display-area'); + const optionsPanel = document.getElementById('options-panel'); + + if (!fileDisplayArea || !optionsPanel) return; + + fileDisplayArea.innerHTML = ''; + + if (files.length > 0) { + optionsPanel.classList.remove('hidden'); + + files.forEach((file) => { + const fileDiv = document.createElement('div'); + fileDiv.className = + 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm'; + + const infoContainer = document.createElement('div'); + infoContainer.className = 'flex flex-col overflow-hidden'; + + const nameSpan = document.createElement('div'); + nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1'; + nameSpan.textContent = file.name; + + const metaSpan = document.createElement('div'); + metaSpan.className = 'text-xs text-gray-400'; + metaSpan.textContent = `${formatBytes(file.size)} • Loading pages...`; + + 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 = () => { + files = []; + pdfjsDoc = null; + cachedBaselineData = null; + updateUI(); + }; + + fileDiv.append(infoContainer, removeBtn); + fileDisplayArea.appendChild(fileDiv); + + readFileAsArrayBuffer(file) + .then((buffer: ArrayBuffer) => { + return getPDFDocument(buffer).promise; + }) + .then((pdf: pdfjsLib.PDFDocumentProxy) => { + metaSpan.textContent = `${formatBytes(file.size)} • ${pdf.numPages} page${pdf.numPages !== 1 ? 's' : ''}`; + }) + .catch(() => { + metaSpan.textContent = formatBytes(file.size); + }); + }); + + createIcons({ icons }); + } else { + optionsPanel.classList.add('hidden'); + } +}; + +const resetState = () => { + files = []; + pdfjsDoc = null; + cachedBaselineData = null; + const fileInput = document.getElementById('file-input') as HTMLInputElement; + if (fileInput) fileInput.value = ''; + updateUI(); +}; + +async function processAllPages(): Promise { + if (files.length === 0) { + showAlert('No File', 'Please upload a PDF file first.'); + return; + } + + showLoader('Applying scanner effect...'); + + try { + const settings = getSettings(); + const pdfBytes = (await readFileAsArrayBuffer(files[0])) as ArrayBuffer; + const doc = await getPDFDocument({ data: pdfBytes }).promise; + const newPdfDoc = await PDFDocument.create(); + const dpiScale = settings.resolution / 72; + + for (let i = 1; i <= doc.numPages; i++) { + showLoader(`Processing page ${i} of ${doc.numPages}...`); + + const page = await doc.getPage(i); + const viewport = page.getViewport({ scale: dpiScale }); + const renderCanvas = document.createElement('canvas'); + const renderCtx = renderCanvas.getContext('2d')!; + renderCanvas.width = viewport.width; + renderCanvas.height = viewport.height; + + await page.render({ + canvasContext: renderCtx, + viewport, + canvas: renderCanvas, + }).promise; + + const baseData = renderCtx.getImageData( + 0, + 0, + renderCanvas.width, + renderCanvas.height + ); + const baselineCopy = new ImageData( + new Uint8ClampedArray(baseData.data), + baseData.width, + baseData.height + ); + + const outputCanvas = document.createElement('canvas'); + const pageRotation = + settings.rotate + + (settings.rotateVariance > 0 + ? (Math.random() - 0.5) * 2 * settings.rotateVariance + : 0); + + applyEffects( + baselineCopy, + outputCanvas, + settings, + pageRotation, + dpiScale + ); + + const jpegBlob = await new Promise((resolve) => + outputCanvas.toBlob(resolve, 'image/jpeg', 0.85) + ); + + if (jpegBlob) { + const jpegBytes = await jpegBlob.arrayBuffer(); + const jpegImage = await newPdfDoc.embedJpg(jpegBytes); + const newPage = newPdfDoc.addPage([ + outputCanvas.width, + outputCanvas.height, + ]); + newPage.drawImage(jpegImage, { + x: 0, + y: 0, + width: outputCanvas.width, + height: outputCanvas.height, + }); + } + } + + const resultBytes = await newPdfDoc.save(); + downloadFile( + new Blob([new Uint8Array(resultBytes)], { type: 'application/pdf' }), + 'scanned.pdf' + ); + showAlert( + 'Success', + 'Scanner effect applied successfully!', + 'success', + () => { + resetState(); + } + ); + } catch (e) { + console.error(e); + showAlert( + 'Error', + 'Failed to apply scanner effect. The file might be corrupted.' + ); + } finally { + hideLoader(); + } +} + +const sliderDefaults: { + id: string; + display: string; + suffix: string; + defaultValue: string; +}[] = [ + { + id: 'setting-rotate', + display: 'rotate-value', + suffix: '°', + defaultValue: '0', + }, + { + id: 'setting-rotate-variance', + display: 'rotate-variance-value', + suffix: '°', + defaultValue: '0', + }, + { + id: 'setting-brightness', + display: 'brightness-value', + suffix: '', + defaultValue: '0', + }, + { + id: 'setting-contrast', + display: 'contrast-value', + suffix: '', + defaultValue: '0', + }, + { + id: 'setting-blur', + display: 'blur-value', + suffix: 'px', + defaultValue: '0', + }, + { + id: 'setting-noise', + display: 'noise-value', + suffix: '', + defaultValue: '10', + }, + { + id: 'setting-yellowish', + display: 'yellowish-value', + suffix: '', + defaultValue: '0', + }, + { + id: 'setting-resolution', + display: 'resolution-value', + suffix: ' DPI', + defaultValue: '150', + }, +]; + +function resetSettings(): void { + sliderDefaults.forEach(({ id, display, suffix, defaultValue }) => { + const slider = document.getElementById(id) as HTMLInputElement; + const label = document.getElementById(display); + if (slider) slider.value = defaultValue; + if (label) label.textContent = defaultValue + suffix; + }); + + const grayscale = document.getElementById( + 'setting-grayscale' + ) as HTMLInputElement; + const border = document.getElementById('setting-border') as HTMLInputElement; + if (grayscale) grayscale.checked = false; + if (border) border.checked = false; + + updatePreview(); +} + +function setupSettingsListeners(): void { + sliderDefaults.forEach(({ id, display, suffix }) => { + const slider = document.getElementById(id) as HTMLInputElement; + const label = document.getElementById(display); + if (slider && label) { + slider.addEventListener('input', () => { + label.textContent = slider.value + suffix; + if (id !== 'setting-resolution') { + updatePreview(); + } + }); + } + }); + + const toggleIds = ['setting-grayscale', 'setting-border']; + toggleIds.forEach((id) => { + const toggle = document.getElementById(id) as HTMLInputElement; + if (toggle) { + toggle.addEventListener('change', updatePreview); + } + }); + + const resetBtn = document.getElementById('reset-settings-btn'); + if (resetBtn) { + resetBtn.addEventListener('click', resetSettings); + } +} + +document.addEventListener('DOMContentLoaded', () => { + const fileInput = document.getElementById('file-input') as HTMLInputElement; + const dropZone = document.getElementById('drop-zone'); + const processBtn = document.getElementById('process-btn'); + const backBtn = document.getElementById('back-to-tools'); + + if (backBtn) { + backBtn.addEventListener('click', () => { + window.location.href = import.meta.env.BASE_URL; + }); + } + + const handleFileSelect = async (newFiles: FileList | null) => { + if (!newFiles || newFiles.length === 0) return; + const validFiles = Array.from(newFiles).filter( + (file) => file.type === 'application/pdf' + ); + + if (validFiles.length === 0) { + showAlert('Invalid File', 'Please upload a PDF file.'); + return; + } + + files = [validFiles[0]]; + updateUI(); + + showLoader('Loading preview...'); + try { + const buffer = await readFileAsArrayBuffer(validFiles[0]); + pdfjsDoc = await getPDFDocument({ data: buffer }).promise; + await renderPreview(); + } catch (e) { + console.error(e); + showAlert('Error', 'Failed to load PDF for preview.'); + } finally { + hideLoader(); + } + }; + + if (fileInput && dropZone) { + fileInput.addEventListener('change', (e) => { + handleFileSelect((e.target as HTMLInputElement).files); + }); + + dropZone.addEventListener('dragover', (e) => { + e.preventDefault(); + dropZone.classList.add('bg-gray-700'); + }); + + dropZone.addEventListener('dragleave', (e) => { + e.preventDefault(); + dropZone.classList.remove('bg-gray-700'); + }); + + dropZone.addEventListener('drop', (e) => { + e.preventDefault(); + dropZone.classList.remove('bg-gray-700'); + handleFileSelect(e.dataTransfer?.files ?? null); + }); + + fileInput.addEventListener('click', () => { + fileInput.value = ''; + }); + } + + if (processBtn) { + processBtn.addEventListener('click', processAllPages); + } + + setupSettingsListeners(); +}); diff --git a/src/js/logic/split-pdf-page.ts b/src/js/logic/split-pdf-page.ts index e3a68ac..699c248 100644 --- a/src/js/logic/split-pdf-page.ts +++ b/src/js/logic/split-pdf-page.ts @@ -1,576 +1,656 @@ import { showLoader, hideLoader, showAlert } from '../ui.js'; import { createIcons, icons } from 'lucide'; import * as pdfjsLib from 'pdfjs-dist'; -import { downloadFile, getPDFDocument, readFileAsArrayBuffer, formatBytes } from '../utils/helpers.js'; +import { + downloadFile, + getPDFDocument, + readFileAsArrayBuffer, + formatBytes, +} from '../utils/helpers.js'; import { state } from '../state.js'; -import { renderPagesProgressively, cleanupLazyRendering } from '../utils/render-utils.js'; +import { + renderPagesProgressively, + cleanupLazyRendering, +} from '../utils/render-utils.js'; +import { initPagePreview } from '../utils/page-preview.js'; +import { isCpdfAvailable } from '../utils/cpdf-helper.js'; +import { showWasmRequiredDialog } from '../utils/wasm-provider.js'; import JSZip from 'jszip'; import { PDFDocument as PDFLibDocument } from 'pdf-lib'; // @ts-ignore -pdfjsLib.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).toString(); +pdfjsLib.GlobalWorkerOptions.workerSrc = new URL( + 'pdfjs-dist/build/pdf.worker.min.mjs', + import.meta.url +).toString(); document.addEventListener('DOMContentLoaded', () => { - let visualSelectorRendered = false; + let visualSelectorRendered = false; - const fileInput = document.getElementById('file-input') as HTMLInputElement; - const dropZone = document.getElementById('drop-zone'); - const processBtn = document.getElementById('process-btn'); - const fileDisplayArea = document.getElementById('file-display-area'); - const splitOptions = document.getElementById('split-options'); - const backBtn = document.getElementById('back-to-tools'); + const fileInput = document.getElementById('file-input') as HTMLInputElement; + const dropZone = document.getElementById('drop-zone'); + const processBtn = document.getElementById('process-btn'); + const fileDisplayArea = document.getElementById('file-display-area'); + const splitOptions = document.getElementById('split-options'); + const backBtn = document.getElementById('back-to-tools'); - // Split Mode Elements - const splitModeSelect = document.getElementById('split-mode') as HTMLSelectElement; - const rangePanel = document.getElementById('range-panel'); - const visualPanel = document.getElementById('visual-select-panel'); - const evenOddPanel = document.getElementById('even-odd-panel'); - const zipOptionWrapper = document.getElementById('zip-option-wrapper'); - const allPagesPanel = document.getElementById('all-pages-panel'); - const bookmarksPanel = document.getElementById('bookmarks-panel'); - const nTimesPanel = document.getElementById('n-times-panel'); - const nTimesWarning = document.getElementById('n-times-warning'); + // Split Mode Elements + const splitModeSelect = document.getElementById( + 'split-mode' + ) as HTMLSelectElement; + const rangePanel = document.getElementById('range-panel'); + const visualPanel = document.getElementById('visual-select-panel'); + const evenOddPanel = document.getElementById('even-odd-panel'); + const zipOptionWrapper = document.getElementById('zip-option-wrapper'); + const allPagesPanel = document.getElementById('all-pages-panel'); + const bookmarksPanel = document.getElementById('bookmarks-panel'); + const nTimesPanel = document.getElementById('n-times-panel'); + const nTimesWarning = document.getElementById('n-times-warning'); - if (backBtn) { - backBtn.addEventListener('click', () => { - window.location.href = import.meta.env.BASE_URL; + if (backBtn) { + backBtn.addEventListener('click', () => { + window.location.href = import.meta.env.BASE_URL; + }); + } + + const updateUI = async () => { + if (state.files.length > 0) { + const file = state.files[0]; + if (fileDisplayArea) { + fileDisplayArea.innerHTML = ''; + const fileDiv = document.createElement('div'); + fileDiv.className = + 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm'; + + const infoContainer = document.createElement('div'); + infoContainer.className = 'flex flex-col overflow-hidden'; + + const nameSpan = document.createElement('div'); + nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1'; + nameSpan.textContent = file.name; + + const metaSpan = document.createElement('div'); + metaSpan.className = 'text-xs text-gray-400'; + metaSpan.textContent = `${formatBytes(file.size)} • Loading pages...`; // Placeholder + + infoContainer.append(nameSpan, metaSpan); + + // Add remove button + const removeBtn = document.createElement('button'); + removeBtn.className = + 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0'; + removeBtn.innerHTML = ''; + removeBtn.onclick = () => { + state.files = []; + state.pdfDoc = null; + updateUI(); + }; + + fileDiv.append(infoContainer, removeBtn); + fileDisplayArea.appendChild(fileDiv); + createIcons({ icons }); + + // Load PDF Document + try { + if (!state.pdfDoc) { + showLoader('Loading PDF...'); + const arrayBuffer = (await readFileAsArrayBuffer( + file + )) as ArrayBuffer; + state.pdfDoc = await PDFLibDocument.load(arrayBuffer); + hideLoader(); + } + // Update page count + metaSpan.textContent = `${formatBytes(file.size)} • ${state.pdfDoc.getPageCount()} pages`; + } catch (error) { + console.error('Error loading PDF:', error); + showAlert('Error', 'Failed to load PDF file.'); + state.files = []; + updateUI(); + return; + } + } + + if (splitOptions) splitOptions.classList.remove('hidden'); + } else { + if (fileDisplayArea) fileDisplayArea.innerHTML = ''; + if (splitOptions) splitOptions.classList.add('hidden'); + state.pdfDoc = null; + } + }; + + const renderVisualSelector = async () => { + if (visualSelectorRendered) return; + + const container = document.getElementById('page-selector-grid'); + if (!container) return; + + visualSelectorRendered = true; + container.textContent = ''; + + // Cleanup any previous lazy loading observers + cleanupLazyRendering(); + + showLoader('Rendering page previews...'); + + try { + if (!state.pdfDoc) { + // If pdfDoc is not loaded yet (e.g. page refresh), try to load it from the first file + if (state.files.length > 0) { + const file = state.files[0]; + const arrayBuffer = (await readFileAsArrayBuffer( + file + )) as ArrayBuffer; + state.pdfDoc = await PDFLibDocument.load(arrayBuffer); + } else { + throw new Error('No PDF document loaded'); + } + } + + const pdfData = await state.pdfDoc.save(); + const pdf = await getPDFDocument({ data: pdfData }).promise; + + // Function to create wrapper element for each page + const createWrapper = (canvas: HTMLCanvasElement, pageNumber: number) => { + const wrapper = document.createElement('div'); + wrapper.className = + 'page-thumbnail-wrapper p-2 border-2 border-gray-600 rounded-lg cursor-pointer hover:border-indigo-500 bg-gray-700 transition-colors relative group flex flex-col items-center gap-1'; + wrapper.dataset.pageIndex = (pageNumber - 1).toString(); + wrapper.dataset.pageNumber = pageNumber.toString(); + + const imgContainer = document.createElement('div'); + imgContainer.className = 'relative'; + + const img = document.createElement('img'); + img.src = canvas.toDataURL(); + img.className = 'rounded-md shadow-md max-w-full h-auto'; + + const pageNumDiv = document.createElement('div'); + pageNumDiv.className = + 'absolute top-1 left-1 bg-indigo-600 text-white text-xs px-2 py-1 rounded-md font-semibold shadow-lg z-10 pointer-events-none'; + pageNumDiv.textContent = pageNumber.toString(); + + imgContainer.append(img, pageNumDiv); + wrapper.appendChild(imgContainer); + + const handleSelection = (e: any) => { + e.preventDefault(); + e.stopPropagation(); + + const isSelected = wrapper.classList.contains('selected'); + + if (isSelected) { + wrapper.classList.remove('selected', 'border-indigo-500'); + wrapper.classList.add('border-gray-600'); + } else { + wrapper.classList.add('selected', 'border-indigo-500'); + wrapper.classList.remove('border-gray-600'); + } + }; + + wrapper.addEventListener('click', handleSelection); + wrapper.addEventListener('touchend', handleSelection); + + wrapper.addEventListener('touchstart', (e) => { + e.preventDefault(); }); + + return wrapper; + }; + + // Render pages progressively with lazy loading + await renderPagesProgressively(pdf, container, createWrapper, { + batchSize: 8, + useLazyLoading: true, + lazyLoadMargin: '400px', + onProgress: (current, total) => { + showLoader(`Rendering page previews: ${current}/${total}`); + }, + onBatchComplete: () => { + createIcons({ icons }); + }, + }); + + initPagePreview(container, pdf); + } catch (error) { + console.error('Error rendering visual selector:', error); + showAlert('Error', 'Failed to render page previews.'); + // Reset the flag on error so the user can try again. + visualSelectorRendered = false; + } finally { + hideLoader(); + } + }; + + const resetState = () => { + state.files = []; + state.pdfDoc = null; + + // Reset visual selection + document + .querySelectorAll('.page-thumbnail-wrapper.selected') + .forEach((el) => { + el.classList.remove('selected', 'border-indigo-500'); + el.classList.add('border-transparent'); + }); + visualSelectorRendered = false; + const container = document.getElementById('page-selector-grid'); + if (container) container.innerHTML = ''; + + // Reset inputs + const pageRangeInput = document.getElementById( + 'page-range' + ) as HTMLInputElement; + if (pageRangeInput) pageRangeInput.value = ''; + + const nValueInput = document.getElementById( + 'split-n-value' + ) as HTMLInputElement; + if (nValueInput) nValueInput.value = '5'; + + // Reset radio buttons to default (range) + const rangeRadio = document.querySelector( + 'input[name="split-mode"][value="range"]' + ) as HTMLInputElement; + if (rangeRadio) { + rangeRadio.checked = true; + rangeRadio.dispatchEvent(new Event('change')); } - const updateUI = async () => { - if (state.files.length > 0) { - const file = state.files[0]; - if (fileDisplayArea) { - fileDisplayArea.innerHTML = ''; - const fileDiv = document.createElement('div'); - fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm'; + // Reset split mode select + if (splitModeSelect) { + splitModeSelect.value = 'range'; + splitModeSelect.dispatchEvent(new Event('change')); + } - const infoContainer = document.createElement('div'); - infoContainer.className = 'flex flex-col overflow-hidden'; + updateUI(); + }; - const nameSpan = document.createElement('div'); - nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1'; - nameSpan.textContent = file.name; + const split = async () => { + const splitMode = splitModeSelect.value; + const downloadAsZip = + (document.getElementById('download-as-zip') as HTMLInputElement) + ?.checked || false; - const metaSpan = document.createElement('div'); - metaSpan.className = 'text-xs text-gray-400'; - metaSpan.textContent = `${formatBytes(file.size)} • Loading pages...`; // Placeholder + showLoader('Splitting PDF...'); - infoContainer.append(nameSpan, metaSpan); + try { + if (!state.pdfDoc) throw new Error('No PDF document loaded.'); - // Add remove button - const removeBtn = document.createElement('button'); - removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0'; - removeBtn.innerHTML = ''; - removeBtn.onclick = () => { - state.files = []; - state.pdfDoc = null; - updateUI(); - }; + const totalPages = state.pdfDoc.getPageCount(); + let indicesToExtract: number[] = []; - fileDiv.append(infoContainer, removeBtn); - fileDisplayArea.appendChild(fileDiv); - createIcons({ icons }); + switch (splitMode) { + case 'range': + const pageRangeInput = ( + document.getElementById('page-range') as HTMLInputElement + ).value; + if (!pageRangeInput) throw new Error('Choose a valid page range.'); + const ranges = pageRangeInput.split(','); - // Load PDF Document - try { - if (!state.pdfDoc) { - showLoader('Loading PDF...'); - const arrayBuffer = await readFileAsArrayBuffer(file) as ArrayBuffer; - state.pdfDoc = await PDFLibDocument.load(arrayBuffer); - hideLoader(); - } - // Update page count - metaSpan.textContent = `${formatBytes(file.size)} • ${state.pdfDoc.getPageCount()} pages`; - } catch (error) { - console.error('Error loading PDF:', error); - showAlert('Error', 'Failed to load PDF file.'); - state.files = []; - updateUI(); - return; - } + const rangeGroups: number[][] = []; + for (const range of ranges) { + const trimmedRange = range.trim(); + if (!trimmedRange) continue; + + const groupIndices: number[] = []; + if (trimmedRange.includes('-')) { + const [start, end] = trimmedRange.split('-').map(Number); + if ( + isNaN(start) || + isNaN(end) || + start < 1 || + end > totalPages || + start > end + ) + continue; + for (let i = start; i <= end; i++) groupIndices.push(i - 1); + } else { + const pageNum = Number(trimmedRange); + if (isNaN(pageNum) || pageNum < 1 || pageNum > totalPages) + continue; + groupIndices.push(pageNum - 1); } - if (splitOptions) splitOptions.classList.remove('hidden'); + if (groupIndices.length > 0) { + rangeGroups.push(groupIndices); + indicesToExtract.push(...groupIndices); + } + } - } else { - if (fileDisplayArea) fileDisplayArea.innerHTML = ''; - if (splitOptions) splitOptions.classList.add('hidden'); - state.pdfDoc = null; - } - }; + if (rangeGroups.length > 1) { + showLoader('Creating separate PDFs for each range...'); + const zip = new JSZip(); - const renderVisualSelector = async () => { - if (visualSelectorRendered) return; + for (let i = 0; i < rangeGroups.length; i++) { + const group = rangeGroups[i]; + const newPdf = await PDFLibDocument.create(); + const copiedPages = await newPdf.copyPages(state.pdfDoc, group); + copiedPages.forEach((page: any) => newPdf.addPage(page)); + const pdfBytes = await newPdf.save(); - const container = document.getElementById('page-selector-grid'); - if (!container) return; - - visualSelectorRendered = true; - container.textContent = ''; - - // Cleanup any previous lazy loading observers - cleanupLazyRendering(); - - showLoader('Rendering page previews...'); - - try { - if (!state.pdfDoc) { - // If pdfDoc is not loaded yet (e.g. page refresh), try to load it from the first file - if (state.files.length > 0) { - const file = state.files[0]; - const arrayBuffer = await readFileAsArrayBuffer(file) as ArrayBuffer; - state.pdfDoc = await PDFLibDocument.load(arrayBuffer); - } else { - throw new Error('No PDF document loaded'); - } + const minPage = Math.min(...group) + 1; + const maxPage = Math.max(...group) + 1; + const filename = + minPage === maxPage + ? `page-${minPage}.pdf` + : `pages-${minPage}-${maxPage}.pdf`; + zip.file(filename, pdfBytes); } - const pdfData = await state.pdfDoc.save(); - const pdf = await getPDFDocument({ data: pdfData }).promise; - - // Function to create wrapper element for each page - const createWrapper = (canvas: HTMLCanvasElement, pageNumber: number) => { - const wrapper = document.createElement('div'); - wrapper.className = - 'page-thumbnail-wrapper p-1 border-2 border-transparent rounded-lg cursor-pointer hover:border-indigo-500 relative'; - wrapper.dataset.pageIndex = (pageNumber - 1).toString(); - - const img = document.createElement('img'); - img.src = canvas.toDataURL(); - img.className = 'rounded-md w-full h-auto'; - - const p = document.createElement('p'); - p.className = 'text-center text-xs mt-1 text-gray-300'; - p.textContent = `Page ${pageNumber}`; - - wrapper.append(img, p); - - const handleSelection = (e: any) => { - e.preventDefault(); - e.stopPropagation(); - - const isSelected = wrapper.classList.contains('selected'); - - if (isSelected) { - wrapper.classList.remove('selected', 'border-indigo-500'); - wrapper.classList.add('border-transparent'); - } else { - wrapper.classList.add('selected', 'border-indigo-500'); - wrapper.classList.remove('border-transparent'); - } - }; - - wrapper.addEventListener('click', handleSelection); - wrapper.addEventListener('touchend', handleSelection); - - wrapper.addEventListener('touchstart', (e) => { - e.preventDefault(); - }); - - return wrapper; - }; - - // Render pages progressively with lazy loading - await renderPagesProgressively( - pdf, - container, - createWrapper, - { - batchSize: 8, - useLazyLoading: true, - lazyLoadMargin: '400px', - onProgress: (current, total) => { - showLoader(`Rendering page previews: ${current}/${total}`); - }, - onBatchComplete: () => { - createIcons({ icons }); - } - } - ); - } catch (error) { - console.error('Error rendering visual selector:', error); - showAlert('Error', 'Failed to render page previews.'); - // Reset the flag on error so the user can try again. - visualSelectorRendered = false; - } finally { + const zipBlob = await zip.generateAsync({ type: 'blob' }); + downloadFile(zipBlob, 'split-pages.zip'); hideLoader(); + showAlert( + 'Success', + `PDF split into ${rangeGroups.length} files successfully!`, + 'success', + () => { + resetState(); + } + ); + return; + } + break; + + case 'even-odd': + const choiceElement = document.querySelector( + 'input[name="even-odd-choice"]:checked' + ) as HTMLInputElement; + if (!choiceElement) + throw new Error('Please select even or odd pages.'); + const choice = choiceElement.value; + for (let i = 0; i < totalPages; i++) { + if (choice === 'even' && (i + 1) % 2 === 0) + indicesToExtract.push(i); + if (choice === 'odd' && (i + 1) % 2 !== 0) indicesToExtract.push(i); + } + break; + case 'all': + indicesToExtract = Array.from({ length: totalPages }, (_, i) => i); + break; + case 'visual': + indicesToExtract = Array.from( + document.querySelectorAll('.page-thumbnail-wrapper.selected') + ).map((el) => parseInt((el as HTMLElement).dataset.pageIndex || '0')); + break; + case 'bookmarks': + // Check if CPDF is configured + if (!isCpdfAvailable()) { + showWasmRequiredDialog('cpdf'); + hideLoader(); + return; + } + const { getCpdf } = await import('../utils/cpdf-helper.js'); + const cpdf = await getCpdf(); + const pdfBytes = await state.pdfDoc.save(); + const pdf = cpdf.fromMemory(new Uint8Array(pdfBytes), ''); + + cpdf.startGetBookmarkInfo(pdf); + const bookmarkCount = cpdf.numberBookmarks(); + const bookmarkLevel = ( + document.getElementById('bookmark-level') as HTMLSelectElement + )?.value; + + const splitPages: number[] = []; + for (let i = 0; i < bookmarkCount; i++) { + const level = cpdf.getBookmarkLevel(i); + const page = cpdf.getBookmarkPage(pdf, i); + + if (bookmarkLevel === 'all' || level === parseInt(bookmarkLevel)) { + if (page > 1 && !splitPages.includes(page - 1)) { + splitPages.push(page - 1); // Convert to 0-based index + } + } + } + cpdf.endGetBookmarkInfo(); + cpdf.deletePdf(pdf); + + if (splitPages.length === 0) { + throw new Error('No bookmarks found at the selected level.'); + } + + splitPages.sort((a, b) => a - b); + const zip = new JSZip(); + + for (let i = 0; i < splitPages.length; i++) { + const startPage = i === 0 ? 0 : splitPages[i]; + const endPage = + i < splitPages.length - 1 + ? splitPages[i + 1] - 1 + : totalPages - 1; + + const newPdf = await PDFLibDocument.create(); + const pageIndices = Array.from( + { length: endPage - startPage + 1 }, + (_, idx) => startPage + idx + ); + const copiedPages = await newPdf.copyPages( + state.pdfDoc, + pageIndices + ); + copiedPages.forEach((page: any) => newPdf.addPage(page)); + const pdfBytes2 = await newPdf.save(); + zip.file(`split-${i + 1}.pdf`, pdfBytes2); + } + + const zipBlob = await zip.generateAsync({ type: 'blob' }); + downloadFile(zipBlob, 'split-by-bookmarks.zip'); + hideLoader(); + showAlert('Success', 'PDF split successfully!', 'success', () => { + resetState(); + }); + return; + + case 'n-times': + const nValue = parseInt( + (document.getElementById('split-n-value') as HTMLInputElement) + ?.value || '5' + ); + if (nValue < 1) throw new Error('N must be at least 1.'); + + const zip2 = new JSZip(); + const numSplits = Math.ceil(totalPages / nValue); + + for (let i = 0; i < numSplits; i++) { + const startPage = i * nValue; + const endPage = Math.min(startPage + nValue - 1, totalPages - 1); + const pageIndices = Array.from( + { length: endPage - startPage + 1 }, + (_, idx) => startPage + idx + ); + + const newPdf = await PDFLibDocument.create(); + const copiedPages = await newPdf.copyPages( + state.pdfDoc, + pageIndices + ); + copiedPages.forEach((page: any) => newPdf.addPage(page)); + const pdfBytes3 = await newPdf.save(); + zip2.file(`split-${i + 1}.pdf`, pdfBytes3); + } + + const zipBlob2 = await zip2.generateAsync({ type: 'blob' }); + downloadFile(zipBlob2, 'split-n-times.zip'); + hideLoader(); + showAlert('Success', 'PDF split successfully!', 'success', () => { + resetState(); + }); + return; + } + + const uniqueIndices = [...new Set(indicesToExtract)]; + if ( + uniqueIndices.length === 0 && + splitMode !== 'bookmarks' && + splitMode !== 'n-times' + ) { + throw new Error('No pages were selected for splitting.'); + } + + if ( + splitMode === 'all' || + (['range', 'visual'].includes(splitMode) && downloadAsZip) + ) { + showLoader('Creating ZIP file...'); + const zip = new JSZip(); + for (const index of uniqueIndices) { + const newPdf = await PDFLibDocument.create(); + const [copiedPage] = await newPdf.copyPages(state.pdfDoc, [ + index as number, + ]); + newPdf.addPage(copiedPage); + const pdfBytes = await newPdf.save(); + // @ts-ignore + zip.file(`page-${index + 1}.pdf`, pdfBytes); } - }; + const zipBlob = await zip.generateAsync({ type: 'blob' }); + downloadFile(zipBlob, 'split-pages.zip'); + } else { + const newPdf = await PDFLibDocument.create(); + const copiedPages = await newPdf.copyPages( + state.pdfDoc, + uniqueIndices as number[] + ); + copiedPages.forEach((page: any) => newPdf.addPage(page)); + const pdfBytes = await newPdf.save(); + downloadFile( + new Blob([new Uint8Array(pdfBytes)], { type: 'application/pdf' }), + 'split-document.pdf' + ); + } - const resetState = () => { - state.files = []; - state.pdfDoc = null; + if (splitMode === 'visual') { + visualSelectorRendered = false; + } - // Reset visual selection - document.querySelectorAll('.page-thumbnail-wrapper.selected').forEach(el => { - el.classList.remove('selected', 'border-indigo-500'); - el.classList.add('border-transparent'); - }); + showAlert('Success', 'PDF split successfully!', 'success', () => { + resetState(); + }); + } catch (e: any) { + console.error(e); + showAlert( + 'Error', + e.message || 'Failed to split PDF. Please check your selection.' + ); + } finally { + hideLoader(); + } + }; + + const handleFileSelect = async (files: FileList | null) => { + if (files && files.length > 0) { + // Split tool only supports one file at a time + state.files = [files[0]]; + await updateUI(); + } + }; + + if (fileInput && dropZone) { + fileInput.addEventListener('change', (e) => { + handleFileSelect((e.target as HTMLInputElement).files); + }); + + dropZone.addEventListener('dragover', (e) => { + e.preventDefault(); + dropZone.classList.add('bg-gray-700'); + }); + + dropZone.addEventListener('dragleave', (e) => { + e.preventDefault(); + dropZone.classList.remove('bg-gray-700'); + }); + + dropZone.addEventListener('drop', (e) => { + e.preventDefault(); + dropZone.classList.remove('bg-gray-700'); + const files = e.dataTransfer?.files; + if (files) { + const pdfFiles = Array.from(files).filter( + (f) => + f.type === 'application/pdf' || + f.name.toLowerCase().endsWith('.pdf') + ); + if (pdfFiles.length > 0) { + // Take only the first PDF + const dataTransfer = new DataTransfer(); + dataTransfer.items.add(pdfFiles[0]); + handleFileSelect(dataTransfer.files); + } + } + }); + + // Clear value on click to allow re-selecting the same file + fileInput.addEventListener('click', () => { + fileInput.value = ''; + }); + } + + if (splitModeSelect) { + splitModeSelect.addEventListener('change', (e) => { + const mode = (e.target as HTMLSelectElement).value; + + if (mode !== 'visual') { visualSelectorRendered = false; const container = document.getElementById('page-selector-grid'); if (container) container.innerHTML = ''; + } - // Reset inputs - const pageRangeInput = document.getElementById('page-range') as HTMLInputElement; - if (pageRangeInput) pageRangeInput.value = ''; + rangePanel?.classList.add('hidden'); + visualPanel?.classList.add('hidden'); + evenOddPanel?.classList.add('hidden'); + allPagesPanel?.classList.add('hidden'); + bookmarksPanel?.classList.add('hidden'); + nTimesPanel?.classList.add('hidden'); + zipOptionWrapper?.classList.add('hidden'); + if (nTimesWarning) nTimesWarning.classList.add('hidden'); - const nValueInput = document.getElementById('split-n-value') as HTMLInputElement; - if (nValueInput) nValueInput.value = '5'; + if (mode === 'range') { + rangePanel?.classList.remove('hidden'); + zipOptionWrapper?.classList.remove('hidden'); + } else if (mode === 'visual') { + visualPanel?.classList.remove('hidden'); + zipOptionWrapper?.classList.remove('hidden'); + renderVisualSelector(); + } else if (mode === 'even-odd') { + evenOddPanel?.classList.remove('hidden'); + } else if (mode === 'all') { + allPagesPanel?.classList.remove('hidden'); + } else if (mode === 'bookmarks') { + bookmarksPanel?.classList.remove('hidden'); + zipOptionWrapper?.classList.remove('hidden'); + } else if (mode === 'n-times') { + nTimesPanel?.classList.remove('hidden'); + zipOptionWrapper?.classList.remove('hidden'); - // Reset radio buttons to default (range) - const rangeRadio = document.querySelector('input[name="split-mode"][value="range"]') as HTMLInputElement; - if (rangeRadio) { - rangeRadio.checked = true; - rangeRadio.dispatchEvent(new Event('change')); - } - - // Reset split mode select - if (splitModeSelect) { - splitModeSelect.value = 'range'; - splitModeSelect.dispatchEvent(new Event('change')); - } - - updateUI(); - }; - - const split = async () => { - const splitMode = splitModeSelect.value; - const downloadAsZip = - (document.getElementById('download-as-zip') as HTMLInputElement)?.checked || - false; - - showLoader('Splitting PDF...'); - - try { - if (!state.pdfDoc) throw new Error('No PDF document loaded.'); - - const totalPages = state.pdfDoc.getPageCount(); - let indicesToExtract: number[] = []; - - switch (splitMode) { - case 'range': - const pageRangeInput = (document.getElementById('page-range') as HTMLInputElement).value; - if (!pageRangeInput) throw new Error('Choose a valid page range.'); - const ranges = pageRangeInput.split(','); - - const rangeGroups: number[][] = []; - for (const range of ranges) { - const trimmedRange = range.trim(); - if (!trimmedRange) continue; - - const groupIndices: number[] = []; - if (trimmedRange.includes('-')) { - const [start, end] = trimmedRange.split('-').map(Number); - if ( - isNaN(start) || - isNaN(end) || - start < 1 || - end > totalPages || - start > end - ) - continue; - for (let i = start; i <= end; i++) groupIndices.push(i - 1); - } else { - const pageNum = Number(trimmedRange); - if (isNaN(pageNum) || pageNum < 1 || pageNum > totalPages) continue; - groupIndices.push(pageNum - 1); - } - - if (groupIndices.length > 0) { - rangeGroups.push(groupIndices); - indicesToExtract.push(...groupIndices); - } - } - - if (rangeGroups.length > 1) { - showLoader('Creating separate PDFs for each range...'); - const zip = new JSZip(); - - for (let i = 0; i < rangeGroups.length; i++) { - const group = rangeGroups[i]; - const newPdf = await PDFLibDocument.create(); - const copiedPages = await newPdf.copyPages(state.pdfDoc, group); - copiedPages.forEach((page: any) => newPdf.addPage(page)); - const pdfBytes = await newPdf.save(); - - const minPage = Math.min(...group) + 1; - const maxPage = Math.max(...group) + 1; - const filename = minPage === maxPage - ? `page-${minPage}.pdf` - : `pages-${minPage}-${maxPage}.pdf`; - zip.file(filename, pdfBytes); - } - - const zipBlob = await zip.generateAsync({ type: 'blob' }); - downloadFile(zipBlob, 'split-pages.zip'); - hideLoader(); - showAlert('Success', `PDF split into ${rangeGroups.length} files successfully!`, 'success', () => { - resetState(); - }); - return; - } - break; - - case 'even-odd': - const choiceElement = document.querySelector( - 'input[name="even-odd-choice"]:checked' - ) as HTMLInputElement; - if (!choiceElement) throw new Error('Please select even or odd pages.'); - const choice = choiceElement.value; - for (let i = 0; i < totalPages; i++) { - if (choice === 'even' && (i + 1) % 2 === 0) indicesToExtract.push(i); - if (choice === 'odd' && (i + 1) % 2 !== 0) indicesToExtract.push(i); - } - break; - case 'all': - indicesToExtract = Array.from({ length: totalPages }, (_, i) => i); - break; - case 'visual': - indicesToExtract = Array.from( - document.querySelectorAll('.page-thumbnail-wrapper.selected') - ) - .map((el) => parseInt((el as HTMLElement).dataset.pageIndex || '0')); - break; - case 'bookmarks': - const { getCpdf } = await import('../utils/cpdf-helper.js'); - const cpdf = await getCpdf(); - const pdfBytes = await state.pdfDoc.save(); - const pdf = cpdf.fromMemory(new Uint8Array(pdfBytes), ''); - - cpdf.startGetBookmarkInfo(pdf); - const bookmarkCount = cpdf.numberBookmarks(); - const bookmarkLevel = (document.getElementById('bookmark-level') as HTMLSelectElement)?.value; - - const splitPages: number[] = []; - for (let i = 0; i < bookmarkCount; i++) { - const level = cpdf.getBookmarkLevel(i); - const page = cpdf.getBookmarkPage(pdf, i); - - if (bookmarkLevel === 'all' || level === parseInt(bookmarkLevel)) { - if (page > 1 && !splitPages.includes(page - 1)) { - splitPages.push(page - 1); // Convert to 0-based index - } - } - } - cpdf.endGetBookmarkInfo(); - cpdf.deletePdf(pdf); - - if (splitPages.length === 0) { - throw new Error('No bookmarks found at the selected level.'); - } - - splitPages.sort((a, b) => a - b); - const zip = new JSZip(); - - for (let i = 0; i < splitPages.length; i++) { - const startPage = i === 0 ? 0 : splitPages[i]; - const endPage = i < splitPages.length - 1 ? splitPages[i + 1] - 1 : totalPages - 1; - - const newPdf = await PDFLibDocument.create(); - const pageIndices = Array.from({ length: endPage - startPage + 1 }, (_, idx) => startPage + idx); - const copiedPages = await newPdf.copyPages(state.pdfDoc, pageIndices); - copiedPages.forEach((page: any) => newPdf.addPage(page)); - const pdfBytes2 = await newPdf.save(); - zip.file(`split-${i + 1}.pdf`, pdfBytes2); - } - - const zipBlob = await zip.generateAsync({ type: 'blob' }); - downloadFile(zipBlob, 'split-by-bookmarks.zip'); - hideLoader(); - showAlert('Success', 'PDF split successfully!', 'success', () => { - resetState(); - }); - return; - - case 'n-times': - const nValue = parseInt((document.getElementById('split-n-value') as HTMLInputElement)?.value || '5'); - if (nValue < 1) throw new Error('N must be at least 1.'); - - const zip2 = new JSZip(); - const numSplits = Math.ceil(totalPages / nValue); - - for (let i = 0; i < numSplits; i++) { - const startPage = i * nValue; - const endPage = Math.min(startPage + nValue - 1, totalPages - 1); - const pageIndices = Array.from({ length: endPage - startPage + 1 }, (_, idx) => startPage + idx); - - const newPdf = await PDFLibDocument.create(); - const copiedPages = await newPdf.copyPages(state.pdfDoc, pageIndices); - copiedPages.forEach((page: any) => newPdf.addPage(page)); - const pdfBytes3 = await newPdf.save(); - zip2.file(`split-${i + 1}.pdf`, pdfBytes3); - } - - const zipBlob2 = await zip2.generateAsync({ type: 'blob' }); - downloadFile(zipBlob2, 'split-n-times.zip'); - hideLoader(); - showAlert('Success', 'PDF split successfully!', 'success', () => { - resetState(); - }); - return; + const updateWarning = () => { + if (!state.pdfDoc) return; + const totalPages = state.pdfDoc.getPageCount(); + const nValue = parseInt( + (document.getElementById('split-n-value') as HTMLInputElement) + ?.value || '5' + ); + const remainder = totalPages % nValue; + if (remainder !== 0 && nTimesWarning) { + nTimesWarning.classList.remove('hidden'); + const warningText = document.getElementById('n-times-warning-text'); + if (warningText) { + warningText.textContent = `The PDF has ${totalPages} pages, which is not evenly divisible by ${nValue}. The last PDF will contain ${remainder} page(s).`; } + } else if (nTimesWarning) { + nTimesWarning.classList.add('hidden'); + } + }; - const uniqueIndices = [...new Set(indicesToExtract)]; - if (uniqueIndices.length === 0 && splitMode !== 'bookmarks' && splitMode !== 'n-times') { - throw new Error('No pages were selected for splitting.'); - } + updateWarning(); + document + .getElementById('split-n-value') + ?.addEventListener('input', updateWarning); + } + }); + } - if ( - splitMode === 'all' || - (['range', 'visual'].includes(splitMode) && downloadAsZip) - ) { - showLoader('Creating ZIP file...'); - const zip = new JSZip(); - for (const index of uniqueIndices) { - const newPdf = await PDFLibDocument.create(); - const [copiedPage] = await newPdf.copyPages(state.pdfDoc, [ - index as number, - ]); - newPdf.addPage(copiedPage); - const pdfBytes = await newPdf.save(); - // @ts-ignore - zip.file(`page-${index + 1}.pdf`, pdfBytes); - } - const zipBlob = await zip.generateAsync({ type: 'blob' }); - downloadFile(zipBlob, 'split-pages.zip'); - } else { - const newPdf = await PDFLibDocument.create(); - const copiedPages = await newPdf.copyPages( - state.pdfDoc, - uniqueIndices as number[] - ); - copiedPages.forEach((page: any) => newPdf.addPage(page)); - const pdfBytes = await newPdf.save(); - downloadFile( - new Blob([new Uint8Array(pdfBytes)], { type: 'application/pdf' }), - 'split-document.pdf' - ); - } - - if (splitMode === 'visual') { - visualSelectorRendered = false; - } - - showAlert('Success', 'PDF split successfully!', 'success', () => { - resetState(); - }); - - } catch (e: any) { - console.error(e); - showAlert( - 'Error', - e.message || 'Failed to split PDF. Please check your selection.' - ); - } finally { - hideLoader(); - } - }; - - const handleFileSelect = async (files: FileList | null) => { - if (files && files.length > 0) { - // Split tool only supports one file at a time - state.files = [files[0]]; - await updateUI(); - } - }; - - if (fileInput && dropZone) { - fileInput.addEventListener('change', (e) => { - handleFileSelect((e.target as HTMLInputElement).files); - }); - - dropZone.addEventListener('dragover', (e) => { - e.preventDefault(); - dropZone.classList.add('bg-gray-700'); - }); - - dropZone.addEventListener('dragleave', (e) => { - e.preventDefault(); - dropZone.classList.remove('bg-gray-700'); - }); - - dropZone.addEventListener('drop', (e) => { - e.preventDefault(); - dropZone.classList.remove('bg-gray-700'); - const files = e.dataTransfer?.files; - if (files) { - const pdfFiles = Array.from(files).filter(f => f.type === 'application/pdf' || f.name.toLowerCase().endsWith('.pdf')); - if (pdfFiles.length > 0) { - // Take only the first PDF - const dataTransfer = new DataTransfer(); - dataTransfer.items.add(pdfFiles[0]); - handleFileSelect(dataTransfer.files); - } - } - }); - - // Clear value on click to allow re-selecting the same file - fileInput.addEventListener('click', () => { - fileInput.value = ''; - }); - } - - if (splitModeSelect) { - splitModeSelect.addEventListener('change', (e) => { - const mode = (e.target as HTMLSelectElement).value; - - if (mode !== 'visual') { - visualSelectorRendered = false; - const container = document.getElementById('page-selector-grid'); - if (container) container.innerHTML = ''; - } - - rangePanel?.classList.add('hidden'); - visualPanel?.classList.add('hidden'); - evenOddPanel?.classList.add('hidden'); - allPagesPanel?.classList.add('hidden'); - bookmarksPanel?.classList.add('hidden'); - nTimesPanel?.classList.add('hidden'); - zipOptionWrapper?.classList.add('hidden'); - if (nTimesWarning) nTimesWarning.classList.add('hidden'); - - if (mode === 'range') { - rangePanel?.classList.remove('hidden'); - zipOptionWrapper?.classList.remove('hidden'); - } else if (mode === 'visual') { - visualPanel?.classList.remove('hidden'); - zipOptionWrapper?.classList.remove('hidden'); - renderVisualSelector(); - } else if (mode === 'even-odd') { - evenOddPanel?.classList.remove('hidden'); - } else if (mode === 'all') { - allPagesPanel?.classList.remove('hidden'); - } else if (mode === 'bookmarks') { - bookmarksPanel?.classList.remove('hidden'); - zipOptionWrapper?.classList.remove('hidden'); - } else if (mode === 'n-times') { - nTimesPanel?.classList.remove('hidden'); - zipOptionWrapper?.classList.remove('hidden'); - - const updateWarning = () => { - if (!state.pdfDoc) return; - const totalPages = state.pdfDoc.getPageCount(); - const nValue = parseInt((document.getElementById('split-n-value') as HTMLInputElement)?.value || '5'); - const remainder = totalPages % nValue; - if (remainder !== 0 && nTimesWarning) { - nTimesWarning.classList.remove('hidden'); - const warningText = document.getElementById('n-times-warning-text'); - if (warningText) { - warningText.textContent = `The PDF has ${totalPages} pages, which is not evenly divisible by ${nValue}. The last PDF will contain ${remainder} page(s).`; - } - } else if (nTimesWarning) { - nTimesWarning.classList.add('hidden'); - } - }; - - updateWarning(); - document.getElementById('split-n-value')?.addEventListener('input', updateWarning); - } - }); - } - - if (processBtn) { - processBtn.addEventListener('click', split); - } + if (processBtn) { + processBtn.addEventListener('click', split); + } }); diff --git a/src/js/logic/svg-to-pdf-page.ts b/src/js/logic/svg-to-pdf-page.ts index e88f7bd..a65fe08 100644 --- a/src/js/logic/svg-to-pdf-page.ts +++ b/src/js/logic/svg-to-pdf-page.ts @@ -1,246 +1,269 @@ import { createIcons, icons } from 'lucide'; import { showAlert, showLoader, hideLoader } from '../ui.js'; -import { downloadFile, readFileAsArrayBuffer, formatBytes } from '../utils/helpers.js'; +import { + downloadFile, + readFileAsArrayBuffer, + formatBytes, +} from '../utils/helpers.js'; import { PDFDocument as PDFLibDocument } from 'pdf-lib'; +import { + getSelectedQuality, + compressImageBytes, +} from '../utils/image-compress.js'; let files: File[] = []; if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', initializePage); + document.addEventListener('DOMContentLoaded', initializePage); } else { - initializePage(); + initializePage(); } function initializePage() { - createIcons({ icons }); + createIcons({ icons }); - const fileInput = document.getElementById('file-input') as HTMLInputElement; - const dropZone = document.getElementById('drop-zone'); - const addMoreBtn = document.getElementById('add-more-btn'); - const clearFilesBtn = document.getElementById('clear-files-btn'); - const processBtn = document.getElementById('process-btn'); + const fileInput = document.getElementById('file-input') as HTMLInputElement; + const dropZone = document.getElementById('drop-zone'); + const addMoreBtn = document.getElementById('add-more-btn'); + const clearFilesBtn = document.getElementById('clear-files-btn'); + const processBtn = document.getElementById('process-btn'); - if (fileInput) { - fileInput.addEventListener('change', handleFileUpload); - } + if (fileInput) { + fileInput.addEventListener('change', handleFileUpload); + } - 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) { - handleFiles(droppedFiles); - } - }); - - // Clear value on click to allow re-selecting the same file - fileInput?.addEventListener('click', () => { - if (fileInput) fileInput.value = ''; - }); - } - - if (addMoreBtn) { - addMoreBtn.addEventListener('click', () => { - fileInput?.click(); - }); - } - - if (clearFilesBtn) { - clearFilesBtn.addEventListener('click', () => { - files = []; - updateUI(); - }); - } - - if (processBtn) { - processBtn.addEventListener('click', convertToPdf); - } - - document.getElementById('back-to-tools')?.addEventListener('click', () => { - window.location.href = import.meta.env.BASE_URL; + 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) { + handleFiles(droppedFiles); + } + }); + + // Clear value on click to allow re-selecting the same file + fileInput?.addEventListener('click', () => { + if (fileInput) fileInput.value = ''; + }); + } + + if (addMoreBtn) { + addMoreBtn.addEventListener('click', () => { + fileInput?.click(); + }); + } + + if (clearFilesBtn) { + clearFilesBtn.addEventListener('click', () => { + files = []; + updateUI(); + }); + } + + if (processBtn) { + processBtn.addEventListener('click', convertToPdf); + } + + document.getElementById('back-to-tools')?.addEventListener('click', () => { + window.location.href = import.meta.env.BASE_URL; + }); } function handleFileUpload(e: Event) { - const input = e.target as HTMLInputElement; - if (input.files && input.files.length > 0) { - handleFiles(input.files); - } + const input = e.target as HTMLInputElement; + if (input.files && input.files.length > 0) { + handleFiles(input.files); + } } function handleFiles(newFiles: FileList) { - const validFiles = Array.from(newFiles).filter(file => - file.type === 'image/svg+xml' || file.name.toLowerCase().endsWith('.svg') + const validFiles = Array.from(newFiles).filter( + (file) => + file.type === 'image/svg+xml' || file.name.toLowerCase().endsWith('.svg') + ); + + if (validFiles.length < newFiles.length) { + showAlert( + 'Invalid Files', + 'Some files were skipped. Only SVG graphics are allowed.' ); + } - if (validFiles.length < newFiles.length) { - showAlert('Invalid Files', 'Some files were skipped. Only SVG graphics are allowed.'); - } - - if (validFiles.length > 0) { - files = [...files, ...validFiles]; - updateUI(); - } + if (validFiles.length > 0) { + files = [...files, ...validFiles]; + updateUI(); + } } const resetState = () => { - files = []; - updateUI(); + files = []; + updateUI(); }; function updateUI() { - const fileDisplayArea = document.getElementById('file-display-area'); - const fileControls = document.getElementById('file-controls'); - const optionsDiv = document.getElementById('jpg-to-pdf-options'); + const fileDisplayArea = document.getElementById('file-display-area'); + const fileControls = document.getElementById('file-controls'); + const optionsDiv = document.getElementById('jpg-to-pdf-options'); - if (!fileDisplayArea || !fileControls || !optionsDiv) return; + if (!fileDisplayArea || !fileControls || !optionsDiv) return; - fileDisplayArea.innerHTML = ''; + fileDisplayArea.innerHTML = ''; - if (files.length > 0) { - fileControls.classList.remove('hidden'); - optionsDiv.classList.remove('hidden'); + if (files.length > 0) { + fileControls.classList.remove('hidden'); + optionsDiv.classList.remove('hidden'); - files.forEach((file, index) => { - const fileDiv = document.createElement('div'); - fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm'; + files.forEach((file, index) => { + const fileDiv = document.createElement('div'); + fileDiv.className = + 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm'; - const infoContainer = document.createElement('div'); - infoContainer.className = 'flex items-center gap-2 overflow-hidden'; + const infoContainer = document.createElement('div'); + infoContainer.className = 'flex items-center gap-2 overflow-hidden'; - const nameSpan = document.createElement('span'); - nameSpan.className = 'truncate font-medium text-gray-200'; - nameSpan.textContent = file.name; + const nameSpan = document.createElement('span'); + nameSpan.className = 'truncate font-medium text-gray-200'; + nameSpan.textContent = file.name; - const sizeSpan = document.createElement('span'); - sizeSpan.className = 'flex-shrink-0 text-gray-400 text-xs'; - sizeSpan.textContent = `(${formatBytes(file.size)})`; + const sizeSpan = document.createElement('span'); + sizeSpan.className = 'flex-shrink-0 text-gray-400 text-xs'; + sizeSpan.textContent = `(${formatBytes(file.size)})`; - infoContainer.append(nameSpan, sizeSpan); + infoContainer.append(nameSpan, sizeSpan); - const removeBtn = document.createElement('button'); - removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0'; - removeBtn.innerHTML = ''; - removeBtn.onclick = () => { - files = files.filter((_, i) => i !== index); - updateUI(); - }; + const removeBtn = document.createElement('button'); + removeBtn.className = + 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0'; + removeBtn.innerHTML = ''; + removeBtn.onclick = () => { + files = files.filter((_, i) => i !== index); + updateUI(); + }; - fileDiv.append(infoContainer, removeBtn); - fileDisplayArea.appendChild(fileDiv); - }); - createIcons({ icons }); - } else { - fileControls.classList.add('hidden'); - optionsDiv.classList.add('hidden'); - } + fileDiv.append(infoContainer, removeBtn); + fileDisplayArea.appendChild(fileDiv); + }); + createIcons({ icons }); + } else { + fileControls.classList.add('hidden'); + optionsDiv.classList.add('hidden'); + } } function svgToPng(svgText: string): Promise { - return new Promise((resolve, reject) => { - const img = new Image(); + return new Promise((resolve, reject) => { + const img = new Image(); - // Create a proper SVG data URL from the SVG text - const svgBlob = new Blob([svgText], { type: 'image/svg+xml;charset=utf-8' }); - const url = URL.createObjectURL(svgBlob); - - img.onload = () => { - const canvas = document.createElement('canvas'); - // Use a reasonable default size if SVG has no explicit dimensions - const width = img.naturalWidth || img.width || 800; - const height = img.naturalHeight || img.height || 600; - - canvas.width = width; - canvas.height = height; - - const ctx = canvas.getContext('2d'); - if (!ctx) { - URL.revokeObjectURL(url); - return reject(new Error('Could not get canvas context')); - } - - // Fill with white background for transparency - ctx.fillStyle = 'white'; - ctx.fillRect(0, 0, width, height); - ctx.drawImage(img, 0, 0, width, height); - - canvas.toBlob( - async (pngBlob) => { - URL.revokeObjectURL(url); - if (!pngBlob) { - return reject(new Error('Canvas toBlob conversion failed.')); - } - const arrayBuffer = await pngBlob.arrayBuffer(); - resolve(new Uint8Array(arrayBuffer)); - }, - 'image/png' - ); - }; - - img.onerror = () => { - URL.revokeObjectURL(url); - reject(new Error('Failed to load SVG image')); - }; - - img.src = url; + // Create a proper SVG data URL from the SVG text + const svgBlob = new Blob([svgText], { + type: 'image/svg+xml;charset=utf-8', }); + const url = URL.createObjectURL(svgBlob); + + img.onload = () => { + const canvas = document.createElement('canvas'); + // Use a reasonable default size if SVG has no explicit dimensions + const width = img.naturalWidth || img.width || 800; + const height = img.naturalHeight || img.height || 600; + + canvas.width = width; + canvas.height = height; + + const ctx = canvas.getContext('2d'); + if (!ctx) { + URL.revokeObjectURL(url); + return reject(new Error('Could not get canvas context')); + } + + // Fill with white background for transparency + ctx.fillStyle = 'white'; + ctx.fillRect(0, 0, width, height); + ctx.drawImage(img, 0, 0, width, height); + + canvas.toBlob(async (pngBlob) => { + URL.revokeObjectURL(url); + if (!pngBlob) { + return reject(new Error('Canvas toBlob conversion failed.')); + } + const arrayBuffer = await pngBlob.arrayBuffer(); + resolve(new Uint8Array(arrayBuffer)); + }, 'image/png'); + }; + + img.onerror = () => { + URL.revokeObjectURL(url); + reject(new Error('Failed to load SVG image')); + }; + + img.src = url; + }); } async function convertToPdf() { - if (files.length === 0) { - showAlert('No Files', 'Please select at least one SVG file.'); - return; - } + if (files.length === 0) { + showAlert('No Files', 'Please select at least one SVG file.'); + return; + } - showLoader('Creating PDF from SVG files...'); + showLoader('Creating PDF from SVG files...'); - try { - const pdfDoc = await PDFLibDocument.create(); + try { + const pdfDoc = await PDFLibDocument.create(); - for (const file of files) { - try { - // Read SVG as text (not binary) - const svgText = await file.text(); + for (const file of files) { + try { + // Read SVG as text (not binary) + const svgText = await file.text(); - // Convert SVG to PNG via canvas - const pngBytes = await svgToPng(svgText); - const pngImage = await pdfDoc.embedPng(pngBytes); + // Convert SVG to PNG via canvas + const pngBytes = await svgToPng(svgText); + const quality = getSelectedQuality(); + const compressed = await compressImageBytes(pngBytes, quality); + const embeddedImage = + compressed.type === 'jpeg' + ? await pdfDoc.embedJpg(compressed.bytes) + : await pdfDoc.embedPng(compressed.bytes); - const page = pdfDoc.addPage([pngImage.width, pngImage.height]); - page.drawImage(pngImage, { - x: 0, - y: 0, - width: pngImage.width, - height: pngImage.height, - }); - } catch (error) { - console.error(`Failed to process ${file.name}:`, error); - throw new Error(`Could not process "${file.name}". The file may be corrupted.`); - } - } - - const pdfBytes = await pdfDoc.save(); - downloadFile( - new Blob([new Uint8Array(pdfBytes)], { type: 'application/pdf' }), - 'from_svgs.pdf' - ); - showAlert('Success', 'PDF created successfully!', 'success', () => { - resetState(); + const page = pdfDoc.addPage([ + embeddedImage.width, + embeddedImage.height, + ]); + page.drawImage(embeddedImage, { + x: 0, + y: 0, + width: embeddedImage.width, + height: embeddedImage.height, }); - } catch (e: any) { - console.error(e); - showAlert('Conversion Error', e.message); - } finally { - hideLoader(); + } catch (error) { + console.error(`Failed to process ${file.name}:`, error); + throw new Error( + `Could not process "${file.name}". The file may be corrupted.` + ); + } } + + const pdfBytes = await pdfDoc.save(); + downloadFile( + new Blob([new Uint8Array(pdfBytes)], { type: 'application/pdf' }), + 'from_svgs.pdf' + ); + showAlert('Success', 'PDF created successfully!', 'success', () => { + resetState(); + }); + } catch (e: any) { + console.error(e); + showAlert('Conversion Error', e.message); + } finally { + hideLoader(); + } } diff --git a/src/js/logic/table-of-contents.ts b/src/js/logic/table-of-contents.ts index 1850d74..1aa46fa 100644 --- a/src/js/logic/table-of-contents.ts +++ b/src/js/logic/table-of-contents.ts @@ -1,8 +1,14 @@ -import { downloadFile, formatBytes } from "../utils/helpers"; -import { initializeGlobalShortcuts } from "../utils/shortcuts-init.js"; +import { downloadFile, formatBytes } from '../utils/helpers'; +import { initializeGlobalShortcuts } from '../utils/shortcuts-init.js'; +import { isCpdfAvailable } from '../utils/cpdf-helper.js'; +import { + showWasmRequiredDialog, + WasmProvider, +} from '../utils/wasm-provider.js'; - -const worker = new Worker(import.meta.env.BASE_URL + 'workers/table-of-contents.worker.js'); +const worker = new Worker( + import.meta.env.BASE_URL + 'workers/table-of-contents.worker.js' +); let pdfFile: File | null = null; @@ -55,12 +61,13 @@ function showStatus( type: 'success' | 'error' | 'info' = 'info' ) { statusMessage.textContent = message; - statusMessage.className = `mt-4 p-3 rounded-lg text-sm ${type === 'success' - ? 'bg-green-900 text-green-200' - : type === 'error' - ? 'bg-red-900 text-red-200' - : 'bg-blue-900 text-blue-200' - }`; + statusMessage.className = `mt-4 p-3 rounded-lg text-sm ${ + type === 'success' + ? 'bg-green-900 text-green-200' + : type === 'error' + ? 'bg-red-900 text-red-200' + : 'bg-blue-900 text-blue-200' + }`; statusMessage.classList.remove('hidden'); } @@ -130,6 +137,12 @@ async function generateTableOfContents() { return; } + // Check if CPDF is configured + if (!isCpdfAvailable()) { + showWasmRequiredDialog('cpdf'); + return; + } + try { generateBtn.disabled = true; showStatus('Reading file (Main Thread)...', 'info'); @@ -143,13 +156,14 @@ async function generateTableOfContents() { const fontFamily = parseInt(fontFamilySelect.value, 10); const addBookmark = addBookmarkCheckbox.checked; - const message: GenerateTOCMessage = { + const message = { command: 'generate-toc', pdfData: arrayBuffer, title, fontSize, fontFamily, addBookmark, + cpdfUrl: WasmProvider.getUrl('cpdf')! + 'coherentpdf.browser.min.js', }; worker.postMessage(message, [arrayBuffer]); @@ -171,7 +185,10 @@ worker.onmessage = (e: MessageEvent) => { const pdfBytes = new Uint8Array(pdfBytesBuffer); const blob = new Blob([pdfBytes], { type: 'application/pdf' }); - downloadFile(blob, pdfFile?.name.replace('.pdf', '_with_toc.pdf') || 'output_with_toc.pdf'); + downloadFile( + blob, + pdfFile?.name.replace('.pdf', '_with_toc.pdf') || 'output_with_toc.pdf' + ); showStatus( 'Table of contents generated successfully! Download started.', diff --git a/src/js/logic/txt-to-pdf-page.ts b/src/js/logic/txt-to-pdf-page.ts index c8766b0..be41b94 100644 --- a/src/js/logic/txt-to-pdf-page.ts +++ b/src/js/logic/txt-to-pdf-page.ts @@ -1,252 +1,280 @@ import { showLoader, hideLoader, showAlert } from '../ui.js'; import { downloadFile, formatBytes } from '../utils/helpers.js'; import { createIcons, icons } from 'lucide'; -import { PyMuPDF } from '@bentopdf/pymupdf-wasm'; -import { getWasmBaseUrl } from '../config/wasm-cdn-config.js'; +import { isWasmAvailable, getWasmBaseUrl } from '../config/wasm-cdn-config.js'; +import { showWasmRequiredDialog } from '../utils/wasm-provider.js'; +import { loadPyMuPDF, isPyMuPDFAvailable } from '../utils/pymupdf-loader.js'; let files: File[] = []; let currentMode: 'upload' | 'text' = 'upload'; // RTL character detection pattern (Arabic, Hebrew, Persian, etc.) -const RTL_PATTERN = /[\u0590-\u05FF\u0600-\u06FF\u0700-\u074F\u0750-\u077F\u0780-\u07BF\u07C0-\u07FF\u08A0-\u08FF\uFB1D-\uFB4F\uFB50-\uFDFF\uFE70-\uFEFF]/; +const RTL_PATTERN = + /[\u0590-\u05FF\u0600-\u06FF\u0700-\u074F\u0750-\u077F\u0780-\u07BF\u07C0-\u07FF\u08A0-\u08FF\uFB1D-\uFB4F\uFB50-\uFDFF\uFE70-\uFEFF]/; function hasRtlCharacters(text: string): boolean { - return RTL_PATTERN.test(text); + return RTL_PATTERN.test(text); } const updateUI = () => { - const fileDisplayArea = document.getElementById('file-display-area'); - const fileControls = document.getElementById('file-controls'); - const dropZone = document.getElementById('drop-zone'); + const fileDisplayArea = document.getElementById('file-display-area'); + const fileControls = document.getElementById('file-controls'); + const dropZone = document.getElementById('drop-zone'); - if (!fileDisplayArea || !fileControls || !dropZone) return; + if (!fileDisplayArea || !fileControls || !dropZone) return; - fileDisplayArea.innerHTML = ''; + fileDisplayArea.innerHTML = ''; - if (files.length > 0 && currentMode === 'upload') { - dropZone.classList.add('hidden'); - fileControls.classList.remove('hidden'); + if (files.length > 0 && currentMode === 'upload') { + dropZone.classList.add('hidden'); + fileControls.classList.remove('hidden'); - files.forEach((file, index) => { - const fileDiv = document.createElement('div'); - fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm'; + files.forEach((file, index) => { + const fileDiv = document.createElement('div'); + fileDiv.className = + 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm'; - const infoSpan = document.createElement('span'); - infoSpan.className = 'truncate font-medium text-gray-200'; - infoSpan.textContent = file.name; + const infoSpan = document.createElement('span'); + infoSpan.className = 'truncate font-medium text-gray-200'; + infoSpan.textContent = file.name; - const sizeSpan = document.createElement('span'); - sizeSpan.className = 'text-gray-400 text-xs ml-2'; - sizeSpan.textContent = `(${formatBytes(file.size)})`; + const sizeSpan = document.createElement('span'); + sizeSpan.className = 'text-gray-400 text-xs ml-2'; + sizeSpan.textContent = `(${formatBytes(file.size)})`; - const removeBtn = document.createElement('button'); - removeBtn.className = 'ml-4 text-red-400 hover:text-red-300'; - removeBtn.innerHTML = ''; - removeBtn.onclick = () => { - files = files.filter((_, i) => i !== index); - updateUI(); - }; + const removeBtn = document.createElement('button'); + removeBtn.className = 'ml-4 text-red-400 hover:text-red-300'; + removeBtn.innerHTML = ''; + removeBtn.onclick = () => { + files = files.filter((_, i) => i !== index); + updateUI(); + }; - fileDiv.append(infoSpan, sizeSpan, removeBtn); - fileDisplayArea.appendChild(fileDiv); - }); - createIcons({ icons }); - } else { - dropZone.classList.remove('hidden'); - fileControls.classList.add('hidden'); - } + fileDiv.append(infoSpan, sizeSpan, removeBtn); + fileDisplayArea.appendChild(fileDiv); + }); + createIcons({ icons }); + } else { + dropZone.classList.remove('hidden'); + fileControls.classList.add('hidden'); + } }; const resetState = () => { - files = []; - const fileInput = document.getElementById('file-input') as HTMLInputElement; - const textInput = document.getElementById('text-input') as HTMLTextAreaElement; - if (fileInput) fileInput.value = ''; - if (textInput) textInput.value = ''; - updateUI(); + files = []; + const fileInput = document.getElementById('file-input') as HTMLInputElement; + const textInput = document.getElementById( + 'text-input' + ) as HTMLTextAreaElement; + if (fileInput) fileInput.value = ''; + if (textInput) textInput.value = ''; + updateUI(); }; async function convert() { - const fontSize = parseInt((document.getElementById('font-size') as HTMLInputElement).value) || 12; - const pageSizeKey = (document.getElementById('page-size') as HTMLSelectElement).value; - const fontName = (document.getElementById('font-family') as HTMLSelectElement)?.value || 'helv'; - const textColor = (document.getElementById('text-color') as HTMLInputElement)?.value || '#000000'; + const fontSize = + parseInt( + (document.getElementById('font-size') as HTMLInputElement).value + ) || 12; + const pageSizeKey = ( + document.getElementById('page-size') as HTMLSelectElement + ).value; + const fontName = + (document.getElementById('font-family') as HTMLSelectElement)?.value || + 'helv'; + const textColor = + (document.getElementById('text-color') as HTMLInputElement)?.value || + '#000000'; - if (currentMode === 'upload' && files.length === 0) { - showAlert('No Files', 'Please select at least one text file.'); - return; + if (currentMode === 'upload' && files.length === 0) { + showAlert('No Files', 'Please select at least one text file.'); + return; + } + + if (currentMode === 'text') { + const textInput = document.getElementById( + 'text-input' + ) as HTMLTextAreaElement; + if (!textInput.value.trim()) { + showAlert('No Text', 'Please enter some text to convert.'); + return; + } + } + + showLoader('Loading engine...'); + + try { + const pymupdf = await loadPyMuPDF(); + + let textContent = ''; + + if (currentMode === 'upload') { + for (const file of files) { + const text = await file.text(); + textContent += text + '\n\n'; + } + } else { + const textInput = document.getElementById( + 'text-input' + ) as HTMLTextAreaElement; + textContent = textInput.value; } - if (currentMode === 'text') { - const textInput = document.getElementById('text-input') as HTMLTextAreaElement; - if (!textInput.value.trim()) { - showAlert('No Text', 'Please enter some text to convert.'); - return; - } - } + showLoader('Creating PDF...'); - showLoader('Loading engine...'); + const pdfBlob = await pymupdf.textToPdf(textContent, { + fontSize, + pageSize: pageSizeKey as 'a4' | 'letter' | 'legal' | 'a3' | 'a5', + fontName: fontName as 'helv' | 'tiro' | 'cour' | 'times', + textColor, + margins: 72, + }); - try { - const pymupdf = new PyMuPDF(getWasmBaseUrl('pymupdf')); - await pymupdf.load(); + downloadFile(pdfBlob, 'text_to_pdf.pdf'); - let textContent = ''; - - if (currentMode === 'upload') { - for (const file of files) { - const text = await file.text(); - textContent += text + '\n\n'; - } - } else { - const textInput = document.getElementById('text-input') as HTMLTextAreaElement; - textContent = textInput.value; - } - - showLoader('Creating PDF...'); - - const pdfBlob = await pymupdf.textToPdf(textContent, { - fontSize, - pageSize: pageSizeKey as 'a4' | 'letter' | 'legal' | 'a3' | 'a5', - fontName: fontName as 'helv' | 'tiro' | 'cour' | 'times', - textColor, - margins: 72 - }); - - downloadFile(pdfBlob, 'text_to_pdf.pdf'); - - showAlert('Success', 'Text converted to PDF successfully!', 'success', () => { - resetState(); - }); - } catch (e: any) { - console.error('[TxtToPDF] Error:', e); - showAlert('Error', `Failed to convert text to PDF. ${e.message || ''}`); - } finally { - hideLoader(); - } + showAlert( + 'Success', + 'Text converted to PDF successfully!', + 'success', + () => { + resetState(); + } + ); + } catch (e: any) { + console.error('[TxtToPDF] Error:', e); + showAlert('Error', `Failed to convert text to PDF. ${e.message || ''}`); + } finally { + hideLoader(); + } } // Update textarea direction based on RTL detection function updateTextareaDirection(textarea: HTMLTextAreaElement) { - const text = textarea.value; - if (hasRtlCharacters(text)) { - textarea.style.direction = 'rtl'; - textarea.style.textAlign = 'right'; - } else { - textarea.style.direction = 'ltr'; - textarea.style.textAlign = 'left'; - } + const text = textarea.value; + if (hasRtlCharacters(text)) { + textarea.style.direction = 'rtl'; + textarea.style.textAlign = 'right'; + } else { + textarea.style.direction = 'ltr'; + textarea.style.textAlign = 'left'; + } } document.addEventListener('DOMContentLoaded', () => { - const fileInput = document.getElementById('file-input') as HTMLInputElement; - const dropZone = document.getElementById('drop-zone'); - const addMoreBtn = document.getElementById('add-more-btn'); - const clearFilesBtn = document.getElementById('clear-files-btn'); - const processBtn = document.getElementById('process-btn'); - const backBtn = document.getElementById('back-to-tools'); - const uploadModeBtn = document.getElementById('txt-mode-upload-btn'); - const textModeBtn = document.getElementById('txt-mode-text-btn'); - const uploadPanel = document.getElementById('txt-upload-panel'); - const textPanel = document.getElementById('txt-text-panel'); - const textInput = document.getElementById('text-input') as HTMLTextAreaElement; + const fileInput = document.getElementById('file-input') as HTMLInputElement; + const dropZone = document.getElementById('drop-zone'); + const addMoreBtn = document.getElementById('add-more-btn'); + const clearFilesBtn = document.getElementById('clear-files-btn'); + const processBtn = document.getElementById('process-btn'); + const backBtn = document.getElementById('back-to-tools'); + const uploadModeBtn = document.getElementById('txt-mode-upload-btn'); + const textModeBtn = document.getElementById('txt-mode-text-btn'); + const uploadPanel = document.getElementById('txt-upload-panel'); + const textPanel = document.getElementById('txt-text-panel'); + const textInput = document.getElementById( + 'text-input' + ) as HTMLTextAreaElement; - // Back to Tools - if (backBtn) { - backBtn.addEventListener('click', () => { - window.location.href = import.meta.env.BASE_URL; - }); + // Back to Tools + if (backBtn) { + backBtn.addEventListener('click', () => { + window.location.href = import.meta.env.BASE_URL; + }); + } + + // Mode switching + if (uploadModeBtn && textModeBtn && uploadPanel && textPanel) { + uploadModeBtn.addEventListener('click', () => { + currentMode = 'upload'; + uploadModeBtn.classList.remove('bg-gray-700', 'text-gray-300'); + uploadModeBtn.classList.add('bg-indigo-600', 'text-white'); + textModeBtn.classList.remove('bg-indigo-600', 'text-white'); + textModeBtn.classList.add('bg-gray-700', 'text-gray-300'); + uploadPanel.classList.remove('hidden'); + textPanel.classList.add('hidden'); + }); + + textModeBtn.addEventListener('click', () => { + currentMode = 'text'; + textModeBtn.classList.remove('bg-gray-700', 'text-gray-300'); + textModeBtn.classList.add('bg-indigo-600', 'text-white'); + uploadModeBtn.classList.remove('bg-indigo-600', 'text-white'); + uploadModeBtn.classList.add('bg-gray-700', 'text-gray-300'); + textPanel.classList.remove('hidden'); + uploadPanel.classList.add('hidden'); + }); + } + + // RTL auto-detection for textarea + if (textInput) { + textInput.addEventListener('input', () => { + updateTextareaDirection(textInput); + }); + } + + // File handling + const handleFileSelect = (newFiles: FileList | null) => { + if (!newFiles || newFiles.length === 0) return; + const validFiles = Array.from(newFiles).filter( + (file) => + file.name.toLowerCase().endsWith('.txt') || file.type === 'text/plain' + ); + + if (validFiles.length < newFiles.length) { + showAlert( + 'Invalid Files', + 'Some files were skipped. Only text files are allowed.' + ); } - // Mode switching - if (uploadModeBtn && textModeBtn && uploadPanel && textPanel) { - uploadModeBtn.addEventListener('click', () => { - currentMode = 'upload'; - uploadModeBtn.classList.remove('bg-gray-700', 'text-gray-300'); - uploadModeBtn.classList.add('bg-indigo-600', 'text-white'); - textModeBtn.classList.remove('bg-indigo-600', 'text-white'); - textModeBtn.classList.add('bg-gray-700', 'text-gray-300'); - uploadPanel.classList.remove('hidden'); - textPanel.classList.add('hidden'); - }); - - textModeBtn.addEventListener('click', () => { - currentMode = 'text'; - textModeBtn.classList.remove('bg-gray-700', 'text-gray-300'); - textModeBtn.classList.add('bg-indigo-600', 'text-white'); - uploadModeBtn.classList.remove('bg-indigo-600', 'text-white'); - uploadModeBtn.classList.add('bg-gray-700', 'text-gray-300'); - textPanel.classList.remove('hidden'); - uploadPanel.classList.add('hidden'); - }); + if (validFiles.length > 0) { + files = [...files, ...validFiles]; + updateUI(); } + }; - // RTL auto-detection for textarea - if (textInput) { - textInput.addEventListener('input', () => { - updateTextareaDirection(textInput); - }); - } + if (fileInput && dropZone) { + fileInput.addEventListener('change', (e) => { + handleFileSelect((e.target as HTMLInputElement).files); + }); - // File handling - const handleFileSelect = (newFiles: FileList | null) => { - if (!newFiles || newFiles.length === 0) return; - const validFiles = Array.from(newFiles).filter( - (file) => file.name.toLowerCase().endsWith('.txt') || file.type === 'text/plain' - ); + dropZone.addEventListener('dragover', (e) => { + e.preventDefault(); + dropZone.classList.add('bg-gray-700'); + }); - if (validFiles.length < newFiles.length) { - showAlert('Invalid Files', 'Some files were skipped. Only text files are allowed.'); - } + dropZone.addEventListener('dragleave', (e) => { + e.preventDefault(); + dropZone.classList.remove('bg-gray-700'); + }); - if (validFiles.length > 0) { - files = [...files, ...validFiles]; - updateUI(); - } - }; + dropZone.addEventListener('drop', (e) => { + e.preventDefault(); + dropZone.classList.remove('bg-gray-700'); + handleFileSelect(e.dataTransfer?.files ?? null); + }); - if (fileInput && dropZone) { - fileInput.addEventListener('change', (e) => { - handleFileSelect((e.target as HTMLInputElement).files); - }); + fileInput.addEventListener('click', () => { + fileInput.value = ''; + }); + } - dropZone.addEventListener('dragover', (e) => { - e.preventDefault(); - dropZone.classList.add('bg-gray-700'); - }); + if (addMoreBtn && fileInput) { + addMoreBtn.addEventListener('click', () => { + fileInput.click(); + }); + } - dropZone.addEventListener('dragleave', (e) => { - e.preventDefault(); - dropZone.classList.remove('bg-gray-700'); - }); + if (clearFilesBtn) { + clearFilesBtn.addEventListener('click', () => { + files = []; + updateUI(); + }); + } - dropZone.addEventListener('drop', (e) => { - e.preventDefault(); - dropZone.classList.remove('bg-gray-700'); - handleFileSelect(e.dataTransfer?.files ?? null); - }); + if (processBtn) { + processBtn.addEventListener('click', convert); + } - fileInput.addEventListener('click', () => { - fileInput.value = ''; - }); - } - - if (addMoreBtn && fileInput) { - addMoreBtn.addEventListener('click', () => { - fileInput.click(); - }); - } - - if (clearFilesBtn) { - clearFilesBtn.addEventListener('click', () => { - files = []; - updateUI(); - }); - } - - if (processBtn) { - processBtn.addEventListener('click', convert); - } - - createIcons({ icons }); + createIcons({ icons }); }); diff --git a/src/js/logic/wasm-settings-page.ts b/src/js/logic/wasm-settings-page.ts new file mode 100644 index 0000000..4e22310 --- /dev/null +++ b/src/js/logic/wasm-settings-page.ts @@ -0,0 +1,235 @@ +import { createIcons, icons } from 'lucide'; +import { showAlert, showLoader, hideLoader } from '../ui.js'; +import { WasmProvider, type WasmPackage } from '../utils/wasm-provider.js'; +import { clearPyMuPDFCache } from '../utils/pymupdf-loader.js'; +import { clearGhostscriptCache } from '../utils/ghostscript-dynamic-loader.js'; + +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initializePage); +} else { + initializePage(); +} + +function initializePage() { + createIcons({ icons }); + + document.querySelectorAll('.copy-btn').forEach((btn) => { + btn.addEventListener('click', async () => { + const url = btn.getAttribute('data-copy'); + if (url) { + await navigator.clipboard.writeText(url); + const svg = btn.querySelector('svg'); + if (svg) { + const checkIcon = document.createElement('i'); + checkIcon.setAttribute('data-lucide', 'check'); + checkIcon.className = 'w-3.5 h-3.5'; + svg.replaceWith(checkIcon); + createIcons({ icons }); + + setTimeout(() => { + const newSvg = btn.querySelector('svg'); + if (newSvg) { + const copyIcon = document.createElement('i'); + copyIcon.setAttribute('data-lucide', 'copy'); + copyIcon.className = 'w-3.5 h-3.5'; + newSvg.replaceWith(copyIcon); + createIcons({ icons }); + } + }, 1500); + } + } + }); + }); + + const pymupdfUrl = document.getElementById('pymupdf-url') as HTMLInputElement; + const pymupdfTest = document.getElementById( + 'pymupdf-test' + ) as HTMLButtonElement; + const pymupdfStatus = document.getElementById( + 'pymupdf-status' + ) as HTMLSpanElement; + + const ghostscriptUrl = document.getElementById( + 'ghostscript-url' + ) as HTMLInputElement; + const ghostscriptTest = document.getElementById( + 'ghostscript-test' + ) as HTMLButtonElement; + const ghostscriptStatus = document.getElementById( + 'ghostscript-status' + ) as HTMLSpanElement; + + const cpdfUrl = document.getElementById('cpdf-url') as HTMLInputElement; + const cpdfTest = document.getElementById('cpdf-test') as HTMLButtonElement; + const cpdfStatus = document.getElementById('cpdf-status') as HTMLSpanElement; + + const saveBtn = document.getElementById('save-btn') as HTMLButtonElement; + const clearBtn = document.getElementById('clear-btn') as HTMLButtonElement; + const backBtn = document.getElementById('back-to-tools'); + + backBtn?.addEventListener('click', () => { + window.location.href = import.meta.env.BASE_URL; + }); + + loadConfiguration(); + + function loadConfiguration() { + const packages: { name: WasmPackage; input: HTMLInputElement }[] = [ + { name: 'pymupdf', input: pymupdfUrl }, + { name: 'ghostscript', input: ghostscriptUrl }, + { name: 'cpdf', input: cpdfUrl }, + ]; + + const config = WasmProvider.getAllProviders(); + + for (const { name, input } of packages) { + const url = config[name]; + if (!url) continue; + + if (WasmProvider.isUserConfigured(name)) { + input.value = url; + } else { + input.value = ''; + input.placeholder = `Using default: ${url}`; + } + updateStatus(name, true); + } + } + + function updateStatus( + packageName: WasmPackage, + configured: boolean, + testing = false + ) { + const statusMap: Record = { + pymupdf: pymupdfStatus, + ghostscript: ghostscriptStatus, + cpdf: cpdfStatus, + }; + + const statusEl = statusMap[packageName]; + if (!statusEl) return; + + if (testing) { + statusEl.textContent = 'Testing...'; + statusEl.className = + 'text-xs px-2 py-1 rounded-full bg-yellow-600/30 text-yellow-300'; + } else if (configured && WasmProvider.isUserConfigured(packageName)) { + statusEl.textContent = 'Custom Override'; + statusEl.className = + 'text-xs px-2 py-1 rounded-full bg-blue-600/30 text-blue-300'; + } else if (configured || WasmProvider.hasEnvDefault(packageName)) { + statusEl.textContent = 'Pre-configured'; + statusEl.className = + 'text-xs px-2 py-1 rounded-full bg-green-600/30 text-green-300'; + } else { + statusEl.textContent = 'Not Configured'; + statusEl.className = + 'text-xs px-2 py-1 rounded-full bg-gray-600 text-gray-300'; + } + } + + async function testConnection(packageName: WasmPackage, url: string) { + if (!url.trim()) { + showAlert('Empty URL', 'Please enter a URL to test.'); + return; + } + + updateStatus(packageName, false, true); + + const result = await WasmProvider.validateUrl(packageName, url); + + if (result.valid) { + updateStatus(packageName, true); + showAlert( + 'Success', + `Connection to ${WasmProvider.getPackageDisplayName(packageName)} successful!`, + 'success' + ); + } else { + updateStatus(packageName, false); + showAlert( + 'Connection Failed', + result.error || 'Could not connect to the URL.' + ); + } + } + + pymupdfTest?.addEventListener('click', () => { + testConnection('pymupdf', pymupdfUrl.value); + }); + + ghostscriptTest?.addEventListener('click', () => { + testConnection('ghostscript', ghostscriptUrl.value); + }); + + cpdfTest?.addEventListener('click', () => { + testConnection('cpdf', cpdfUrl.value); + }); + + saveBtn?.addEventListener('click', async () => { + showLoader('Saving configuration...'); + + try { + if (pymupdfUrl.value.trim()) { + WasmProvider.setUrl('pymupdf', pymupdfUrl.value.trim()); + updateStatus('pymupdf', true); + } else { + WasmProvider.removeUrl('pymupdf'); + updateStatus('pymupdf', false); + } + + if (ghostscriptUrl.value.trim()) { + WasmProvider.setUrl('ghostscript', ghostscriptUrl.value.trim()); + updateStatus('ghostscript', true); + } else { + WasmProvider.removeUrl('ghostscript'); + updateStatus('ghostscript', false); + } + + if (cpdfUrl.value.trim()) { + WasmProvider.setUrl('cpdf', cpdfUrl.value.trim()); + updateStatus('cpdf', true); + } else { + WasmProvider.removeUrl('cpdf'); + updateStatus('cpdf', false); + } + + hideLoader(); + showAlert('Saved', 'Configuration saved successfully!', 'success'); + } catch (e: unknown) { + hideLoader(); + const errorMessage = e instanceof Error ? e.message : 'Unknown error'; + showAlert('Error', `Failed to save configuration: ${errorMessage}`); + } + }); + + clearBtn?.addEventListener('click', () => { + WasmProvider.clearAll(); + + clearPyMuPDFCache(); + clearGhostscriptCache(); + + const defaults = WasmProvider.getAllProviders(); + pymupdfUrl.value = defaults.pymupdf || ''; + ghostscriptUrl.value = defaults.ghostscript || ''; + cpdfUrl.value = defaults.cpdf || ''; + + updateStatus('pymupdf', WasmProvider.isConfigured('pymupdf')); + updateStatus('ghostscript', WasmProvider.isConfigured('ghostscript')); + updateStatus('cpdf', WasmProvider.isConfigured('cpdf')); + + const hasDefaults = + WasmProvider.hasEnvDefault('pymupdf') || + WasmProvider.hasEnvDefault('ghostscript') || + WasmProvider.hasEnvDefault('cpdf'); + + showAlert( + 'Reset', + hasDefaults + ? 'Custom overrides cleared. Pre-configured defaults are active.' + : 'All configurations and cached modules have been cleared.', + 'success' + ); + }); +} diff --git a/src/js/logic/webp-to-pdf-page.ts b/src/js/logic/webp-to-pdf-page.ts index 087c8b2..bcf38ae 100644 --- a/src/js/logic/webp-to-pdf-page.ts +++ b/src/js/logic/webp-to-pdf-page.ts @@ -1,248 +1,257 @@ import { createIcons, icons } from 'lucide'; import { showAlert, showLoader, hideLoader } from '../ui.js'; -import { downloadFile, readFileAsArrayBuffer, formatBytes } from '../utils/helpers.js'; +import { + downloadFile, + readFileAsArrayBuffer, + formatBytes, +} from '../utils/helpers.js'; import { PDFDocument as PDFLibDocument } from 'pdf-lib'; +import { + getSelectedQuality, + compressImageBytes, +} from '../utils/image-compress.js'; let files: File[] = []; if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', initializePage); + document.addEventListener('DOMContentLoaded', initializePage); } else { - initializePage(); + initializePage(); } function initializePage() { - createIcons({ icons }); + createIcons({ icons }); - const fileInput = document.getElementById('file-input') as HTMLInputElement; - const dropZone = document.getElementById('drop-zone'); - const addMoreBtn = document.getElementById('add-more-btn'); - const clearFilesBtn = document.getElementById('clear-files-btn'); - const processBtn = document.getElementById('process-btn'); + const fileInput = document.getElementById('file-input') as HTMLInputElement; + const dropZone = document.getElementById('drop-zone'); + const addMoreBtn = document.getElementById('add-more-btn'); + const clearFilesBtn = document.getElementById('clear-files-btn'); + const processBtn = document.getElementById('process-btn'); - if (fileInput) { - fileInput.addEventListener('change', handleFileUpload); - } + if (fileInput) { + fileInput.addEventListener('change', handleFileUpload); + } - 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) { - handleFiles(droppedFiles); - } - }); - - // Clear value on click to allow re-selecting the same file - fileInput?.addEventListener('click', () => { - if (fileInput) fileInput.value = ''; - }); - } - - if (addMoreBtn) { - addMoreBtn.addEventListener('click', () => { - fileInput?.click(); - }); - } - - if (clearFilesBtn) { - clearFilesBtn.addEventListener('click', () => { - files = []; - updateUI(); - }); - } - - if (processBtn) { - processBtn.addEventListener('click', convertToPdf); - } - - document.getElementById('back-to-tools')?.addEventListener('click', () => { - window.location.href = import.meta.env.BASE_URL; + 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) { + handleFiles(droppedFiles); + } + }); + + // Clear value on click to allow re-selecting the same file + fileInput?.addEventListener('click', () => { + if (fileInput) fileInput.value = ''; + }); + } + + if (addMoreBtn) { + addMoreBtn.addEventListener('click', () => { + fileInput?.click(); + }); + } + + if (clearFilesBtn) { + clearFilesBtn.addEventListener('click', () => { + files = []; + updateUI(); + }); + } + + if (processBtn) { + processBtn.addEventListener('click', convertToPdf); + } + + document.getElementById('back-to-tools')?.addEventListener('click', () => { + window.location.href = import.meta.env.BASE_URL; + }); } function handleFileUpload(e: Event) { - const input = e.target as HTMLInputElement; - if (input.files && input.files.length > 0) { - handleFiles(input.files); - } + const input = e.target as HTMLInputElement; + if (input.files && input.files.length > 0) { + handleFiles(input.files); + } } function handleFiles(newFiles: FileList) { - const validFiles = Array.from(newFiles).filter(file => - file.type === 'image/webp' || file.name.toLowerCase().endsWith('.webp') + const validFiles = Array.from(newFiles).filter( + (file) => + file.type === 'image/webp' || file.name.toLowerCase().endsWith('.webp') + ); + + if (validFiles.length < newFiles.length) { + showAlert( + 'Invalid Files', + 'Some files were skipped. Only WebP images are allowed.' ); + } - if (validFiles.length < newFiles.length) { - showAlert('Invalid Files', 'Some files were skipped. Only WebP images are allowed.'); - } - - if (validFiles.length > 0) { - files = [...files, ...validFiles]; - updateUI(); - } + if (validFiles.length > 0) { + files = [...files, ...validFiles]; + updateUI(); + } } const resetState = () => { - files = []; - updateUI(); + files = []; + updateUI(); }; function updateUI() { - const fileDisplayArea = document.getElementById('file-display-area'); - const fileControls = document.getElementById('file-controls'); - const optionsDiv = document.getElementById('jpg-to-pdf-options'); + const fileDisplayArea = document.getElementById('file-display-area'); + const fileControls = document.getElementById('file-controls'); + const optionsDiv = document.getElementById('jpg-to-pdf-options'); - if (!fileDisplayArea || !fileControls || !optionsDiv) return; + if (!fileDisplayArea || !fileControls || !optionsDiv) return; - fileDisplayArea.innerHTML = ''; + fileDisplayArea.innerHTML = ''; - if (files.length > 0) { - fileControls.classList.remove('hidden'); - optionsDiv.classList.remove('hidden'); + if (files.length > 0) { + fileControls.classList.remove('hidden'); + optionsDiv.classList.remove('hidden'); - files.forEach((file, index) => { - const fileDiv = document.createElement('div'); - fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm'; + files.forEach((file, index) => { + const fileDiv = document.createElement('div'); + fileDiv.className = + 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm'; - const infoContainer = document.createElement('div'); - infoContainer.className = 'flex items-center gap-2 overflow-hidden'; + const infoContainer = document.createElement('div'); + infoContainer.className = 'flex items-center gap-2 overflow-hidden'; - const nameSpan = document.createElement('span'); - nameSpan.className = 'truncate font-medium text-gray-200'; - nameSpan.textContent = file.name; + const nameSpan = document.createElement('span'); + nameSpan.className = 'truncate font-medium text-gray-200'; + nameSpan.textContent = file.name; - const sizeSpan = document.createElement('span'); - sizeSpan.className = 'flex-shrink-0 text-gray-400 text-xs'; - sizeSpan.textContent = `(${formatBytes(file.size)})`; + const sizeSpan = document.createElement('span'); + sizeSpan.className = 'flex-shrink-0 text-gray-400 text-xs'; + sizeSpan.textContent = `(${formatBytes(file.size)})`; - infoContainer.append(nameSpan, sizeSpan); + infoContainer.append(nameSpan, sizeSpan); - const removeBtn = document.createElement('button'); - removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0'; - removeBtn.innerHTML = ''; - removeBtn.onclick = () => { - files = files.filter((_, i) => i !== index); - updateUI(); - }; + const removeBtn = document.createElement('button'); + removeBtn.className = + 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0'; + removeBtn.innerHTML = ''; + removeBtn.onclick = () => { + files = files.filter((_, i) => i !== index); + updateUI(); + }; - fileDiv.append(infoContainer, removeBtn); - fileDisplayArea.appendChild(fileDiv); - }); - createIcons({ icons }); - } else { - fileControls.classList.add('hidden'); - optionsDiv.classList.add('hidden'); - } + fileDiv.append(infoContainer, removeBtn); + fileDisplayArea.appendChild(fileDiv); + }); + createIcons({ icons }); + } else { + fileControls.classList.add('hidden'); + optionsDiv.classList.add('hidden'); + } } function sanitizeImageAsJpeg(imageBytes: any) { - return new Promise((resolve, reject) => { - const blob = new Blob([imageBytes]); - const imageUrl = URL.createObjectURL(blob); - const img = new Image(); + return new Promise((resolve, reject) => { + const blob = new Blob([imageBytes]); + const imageUrl = URL.createObjectURL(blob); + const img = new Image(); - img.onload = () => { - const canvas = document.createElement('canvas'); - canvas.width = img.naturalWidth; - canvas.height = img.naturalHeight; - const ctx = canvas.getContext('2d'); - ctx.drawImage(img, 0, 0); + img.onload = () => { + const canvas = document.createElement('canvas'); + canvas.width = img.naturalWidth; + canvas.height = img.naturalHeight; + const ctx = canvas.getContext('2d'); + ctx.drawImage(img, 0, 0); - canvas.toBlob( - async (jpegBlob) => { - if (!jpegBlob) { - return reject(new Error('Canvas toBlob conversion failed.')); - } - const arrayBuffer = await jpegBlob.arrayBuffer(); - resolve(new Uint8Array(arrayBuffer)); - }, - 'image/jpeg', - 0.9 - ); - URL.revokeObjectURL(imageUrl); - }; + canvas.toBlob( + async (jpegBlob) => { + if (!jpegBlob) { + return reject(new Error('Canvas toBlob conversion failed.')); + } + const arrayBuffer = await jpegBlob.arrayBuffer(); + resolve(new Uint8Array(arrayBuffer)); + }, + 'image/jpeg', + 0.9 + ); + URL.revokeObjectURL(imageUrl); + }; - img.onerror = () => { - URL.revokeObjectURL(imageUrl); - reject( - new Error( - 'The provided file could not be loaded as an image. It may be corrupted.' - ) - ); - }; + img.onerror = () => { + URL.revokeObjectURL(imageUrl); + reject( + new Error( + 'The provided file could not be loaded as an image. It may be corrupted.' + ) + ); + }; - img.src = imageUrl; - }); + img.src = imageUrl; + }); } async function convertToPdf() { - if (files.length === 0) { - showAlert('No Files', 'Please select at least one JPG file.'); - return; - } + if (files.length === 0) { + showAlert('No Files', 'Please select at least one JPG file.'); + return; + } - showLoader('Creating PDF from JPGs...'); + showLoader('Creating PDF from JPGs...'); - try { - const pdfDoc = await PDFLibDocument.create(); + try { + const pdfDoc = await PDFLibDocument.create(); + const quality = getSelectedQuality(); - for (const file of files) { - const originalBytes = await readFileAsArrayBuffer(file); - let jpgImage; + for (const file of files) { + const originalBytes = await readFileAsArrayBuffer(file); + const compressed = await compressImageBytes( + new Uint8Array(originalBytes as ArrayBuffer), + quality + ); + let embeddedImage; - try { - jpgImage = await pdfDoc.embedJpg(originalBytes as Uint8Array); - } catch (e) { - showAlert( - 'Warning', - `Direct JPG embedding failed for ${file.name}, attempting to sanitize...` - ); - try { - const sanitizedBytes = await sanitizeImageAsJpeg(originalBytes); - jpgImage = await pdfDoc.embedJpg(sanitizedBytes as Uint8Array); - } catch (fallbackError) { - console.error( - `Failed to process ${file.name} after sanitization:`, - fallbackError - ); - throw new Error( - `Could not process "${file.name}". The file may be corrupted.` - ); - } - } - - const page = pdfDoc.addPage([jpgImage.width, jpgImage.height]); - page.drawImage(jpgImage, { - x: 0, - y: 0, - width: jpgImage.width, - height: jpgImage.height, - }); + if (compressed.type === 'jpeg') { + embeddedImage = await pdfDoc.embedJpg(compressed.bytes); + } else { + try { + embeddedImage = await pdfDoc.embedPng(compressed.bytes); + } catch { + const fallback = await sanitizeImageAsJpeg(originalBytes); + embeddedImage = await pdfDoc.embedJpg(fallback as Uint8Array); } + } - const pdfBytes = await pdfDoc.save(); - downloadFile( - new Blob([new Uint8Array(pdfBytes)], { type: 'application/pdf' }), - 'from_jpgs.pdf' - ); - showAlert('Success', 'PDF created successfully!', 'success', () => { - resetState(); - }); - } catch (e: any) { - console.error(e); - showAlert('Conversion Error', e.message); - } finally { - hideLoader(); + const page = pdfDoc.addPage([embeddedImage.width, embeddedImage.height]); + page.drawImage(embeddedImage, { + x: 0, + y: 0, + width: embeddedImage.width, + height: embeddedImage.height, + }); } + + const pdfBytes = await pdfDoc.save(); + downloadFile( + new Blob([new Uint8Array(pdfBytes)], { type: 'application/pdf' }), + 'from_webps.pdf' + ); + showAlert('Success', 'PDF created successfully!', 'success', () => { + resetState(); + }); + } catch (e: any) { + console.error(e); + showAlert('Conversion Error', e.message); + } finally { + hideLoader(); + } } diff --git a/src/js/logic/xps-to-pdf-page.ts b/src/js/logic/xps-to-pdf-page.ts index f3af175..a0af021 100644 --- a/src/js/logic/xps-to-pdf-page.ts +++ b/src/js/logic/xps-to-pdf-page.ts @@ -2,201 +2,212 @@ import { showLoader, hideLoader, showAlert } from '../ui.js'; import { downloadFile, formatBytes } from '../utils/helpers.js'; import { state } from '../state.js'; import { createIcons, icons } from 'lucide'; -import { PyMuPDF } from '@bentopdf/pymupdf-wasm'; -import { getWasmBaseUrl } from '../config/wasm-cdn-config.js'; +import { isWasmAvailable, getWasmBaseUrl } from '../config/wasm-cdn-config.js'; +import { showWasmRequiredDialog } from '../utils/wasm-provider.js'; +import { loadPyMuPDF, isPyMuPDFAvailable } from '../utils/pymupdf-loader.js'; const FILETYPE = 'xps'; const EXTENSIONS = ['.xps', '.oxps']; const TOOL_NAME = 'XPS'; document.addEventListener('DOMContentLoaded', () => { - const fileInput = document.getElementById('file-input') as HTMLInputElement; - const dropZone = document.getElementById('drop-zone'); - const processBtn = document.getElementById('process-btn'); - const fileDisplayArea = document.getElementById('file-display-area'); - const fileControls = document.getElementById('file-controls'); - const addMoreBtn = document.getElementById('add-more-btn'); - const clearFilesBtn = document.getElementById('clear-files-btn'); - const backBtn = document.getElementById('back-to-tools'); + const fileInput = document.getElementById('file-input') as HTMLInputElement; + const dropZone = document.getElementById('drop-zone'); + const processBtn = document.getElementById('process-btn'); + const fileDisplayArea = document.getElementById('file-display-area'); + const fileControls = document.getElementById('file-controls'); + const addMoreBtn = document.getElementById('add-more-btn'); + const clearFilesBtn = document.getElementById('clear-files-btn'); + const backBtn = document.getElementById('back-to-tools'); - if (backBtn) { - backBtn.addEventListener('click', () => { - window.location.href = import.meta.env.BASE_URL; - }); + if (backBtn) { + backBtn.addEventListener('click', () => { + window.location.href = import.meta.env.BASE_URL; + }); + } + + const updateUI = async () => { + if (!fileDisplayArea || !processBtn || !fileControls) return; + + if (state.files.length > 0) { + fileDisplayArea.innerHTML = ''; + + for (let index = 0; index < state.files.length; index++) { + const file = state.files[index]; + const fileDiv = document.createElement('div'); + fileDiv.className = + 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm'; + + const infoContainer = document.createElement('div'); + infoContainer.className = 'flex flex-col overflow-hidden'; + + const nameSpan = document.createElement('div'); + nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1'; + nameSpan.textContent = file.name; + + const metaSpan = document.createElement('div'); + metaSpan.className = 'text-xs text-gray-400'; + metaSpan.textContent = formatBytes(file.size); + + 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 = () => { + state.files = state.files.filter((_, i) => i !== index); + updateUI(); + }; + + fileDiv.append(infoContainer, removeBtn); + fileDisplayArea.appendChild(fileDiv); + } + + createIcons({ icons }); + fileControls.classList.remove('hidden'); + processBtn.classList.remove('hidden'); + (processBtn as HTMLButtonElement).disabled = false; + } else { + fileDisplayArea.innerHTML = ''; + fileControls.classList.add('hidden'); + processBtn.classList.add('hidden'); + (processBtn as HTMLButtonElement).disabled = true; } + }; - const updateUI = async () => { - if (!fileDisplayArea || !processBtn || !fileControls) return; + const resetState = () => { + state.files = []; + state.pdfDoc = null; + updateUI(); + }; - if (state.files.length > 0) { - fileDisplayArea.innerHTML = ''; + const convertToPdf = async () => { + try { + if (state.files.length === 0) { + showAlert('No Files', `Please select at least one ${TOOL_NAME} file.`); + return; + } - for (let index = 0; index < state.files.length; index++) { - const file = state.files[index]; - const fileDiv = document.createElement('div'); - fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm'; + showLoader('Loading engine...'); + const pymupdf = await loadPyMuPDF(); - const infoContainer = document.createElement('div'); - infoContainer.className = 'flex flex-col overflow-hidden'; + if (state.files.length === 1) { + const originalFile = state.files[0]; + showLoader(`Converting ${originalFile.name}...`); - const nameSpan = document.createElement('div'); - nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1'; - nameSpan.textContent = file.name; + const pdfBlob = await pymupdf.convertToPdf(originalFile, { + filetype: FILETYPE, + }); + const fileName = originalFile.name.replace(/\.[^.]+$/, '') + '.pdf'; - const metaSpan = document.createElement('div'); - metaSpan.className = 'text-xs text-gray-400'; - metaSpan.textContent = formatBytes(file.size); + downloadFile(pdfBlob, fileName); + hideLoader(); - infoContainer.append(nameSpan, metaSpan); + showAlert( + 'Conversion Complete', + `Successfully converted ${originalFile.name} to PDF.`, + 'success', + () => resetState() + ); + } else { + showLoader('Converting files...'); + const JSZip = (await import('jszip')).default; + const zip = new JSZip(); - const removeBtn = document.createElement('button'); - removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0'; - removeBtn.innerHTML = ''; - removeBtn.onclick = () => { - state.files = state.files.filter((_, i) => i !== index); - updateUI(); - }; + for (let i = 0; i < state.files.length; i++) { + const file = state.files[i]; + showLoader( + `Converting ${i + 1}/${state.files.length}: ${file.name}...` + ); - fileDiv.append(infoContainer, removeBtn); - fileDisplayArea.appendChild(fileDiv); - } - - createIcons({ icons }); - fileControls.classList.remove('hidden'); - processBtn.classList.remove('hidden'); - (processBtn as HTMLButtonElement).disabled = false; - } else { - fileDisplayArea.innerHTML = ''; - fileControls.classList.add('hidden'); - processBtn.classList.add('hidden'); - (processBtn as HTMLButtonElement).disabled = true; + const pdfBlob = await pymupdf.convertToPdf(file, { + filetype: FILETYPE, + }); + const baseName = file.name.replace(/\.[^.]+$/, ''); + const pdfBuffer = await pdfBlob.arrayBuffer(); + zip.file(`${baseName}.pdf`, pdfBuffer); } - }; - const resetState = () => { - state.files = []; - state.pdfDoc = null; - updateUI(); - }; + const zipBlob = await zip.generateAsync({ type: 'blob' }); + downloadFile(zipBlob, `${FILETYPE}-converted.zip`); - const convertToPdf = async () => { - try { - if (state.files.length === 0) { - showAlert('No Files', `Please select at least one ${TOOL_NAME} file.`); - return; - } + hideLoader(); - showLoader('Loading engine...'); - const pymupdf = new PyMuPDF(getWasmBaseUrl('pymupdf')); - await pymupdf.load(); + showAlert( + 'Conversion Complete', + `Successfully converted ${state.files.length} ${TOOL_NAME} file(s) to PDF.`, + 'success', + () => resetState() + ); + } + } catch (e: any) { + console.error(`[${TOOL_NAME}2PDF] ERROR:`, e); + hideLoader(); + showAlert( + 'Error', + `An error occurred during conversion. Error: ${e.message}` + ); + } + }; - if (state.files.length === 1) { - const originalFile = state.files[0]; - showLoader(`Converting ${originalFile.name}...`); + const handleFileSelect = (files: FileList | null) => { + if (files && files.length > 0) { + state.files = [...state.files, ...Array.from(files)]; + updateUI(); + } + }; - const pdfBlob = await pymupdf.convertToPdf(originalFile, { filetype: FILETYPE }); - const fileName = originalFile.name.replace(/\.[^.]+$/, '') + '.pdf'; + if (fileInput && dropZone) { + fileInput.addEventListener('change', (e) => { + handleFileSelect((e.target as HTMLInputElement).files); + }); - downloadFile(pdfBlob, fileName); - hideLoader(); + dropZone.addEventListener('dragover', (e) => { + e.preventDefault(); + dropZone.classList.add('bg-gray-700'); + }); - showAlert( - 'Conversion Complete', - `Successfully converted ${originalFile.name} to PDF.`, - 'success', - () => resetState() - ); - } else { - showLoader('Converting files...'); - const JSZip = (await import('jszip')).default; - const zip = new JSZip(); + dropZone.addEventListener('dragleave', (e) => { + e.preventDefault(); + dropZone.classList.remove('bg-gray-700'); + }); - for (let i = 0; i < state.files.length; i++) { - const file = state.files[i]; - showLoader(`Converting ${i + 1}/${state.files.length}: ${file.name}...`); - - const pdfBlob = await pymupdf.convertToPdf(file, { filetype: FILETYPE }); - const baseName = file.name.replace(/\.[^.]+$/, ''); - const pdfBuffer = await pdfBlob.arrayBuffer(); - zip.file(`${baseName}.pdf`, pdfBuffer); - } - - const zipBlob = await zip.generateAsync({ type: 'blob' }); - downloadFile(zipBlob, `${FILETYPE}-converted.zip`); - - hideLoader(); - - showAlert( - 'Conversion Complete', - `Successfully converted ${state.files.length} ${TOOL_NAME} file(s) to PDF.`, - 'success', - () => resetState() - ); - } - } catch (e: any) { - console.error(`[${TOOL_NAME}2PDF] ERROR:`, e); - hideLoader(); - showAlert('Error', `An error occurred during conversion. Error: ${e.message}`); + dropZone.addEventListener('drop', (e) => { + e.preventDefault(); + dropZone.classList.remove('bg-gray-700'); + const files = e.dataTransfer?.files; + if (files && files.length > 0) { + const validFiles = Array.from(files).filter((f) => { + const name = f.name.toLowerCase(); + return EXTENSIONS.some((ext) => name.endsWith(ext)); + }); + if (validFiles.length > 0) { + const dataTransfer = new DataTransfer(); + validFiles.forEach((f) => dataTransfer.items.add(f)); + handleFileSelect(dataTransfer.files); } - }; + } + }); - const handleFileSelect = (files: FileList | null) => { - if (files && files.length > 0) { - state.files = [...state.files, ...Array.from(files)]; - updateUI(); - } - }; + fileInput.addEventListener('click', () => { + fileInput.value = ''; + }); + } - if (fileInput && dropZone) { - fileInput.addEventListener('change', (e) => { - handleFileSelect((e.target as HTMLInputElement).files); - }); + if (addMoreBtn) { + addMoreBtn.addEventListener('click', () => { + fileInput.click(); + }); + } - dropZone.addEventListener('dragover', (e) => { - e.preventDefault(); - dropZone.classList.add('bg-gray-700'); - }); + if (clearFilesBtn) { + clearFilesBtn.addEventListener('click', () => { + resetState(); + }); + } - dropZone.addEventListener('dragleave', (e) => { - e.preventDefault(); - dropZone.classList.remove('bg-gray-700'); - }); - - dropZone.addEventListener('drop', (e) => { - e.preventDefault(); - dropZone.classList.remove('bg-gray-700'); - const files = e.dataTransfer?.files; - if (files && files.length > 0) { - const validFiles = Array.from(files).filter(f => { - const name = f.name.toLowerCase(); - return EXTENSIONS.some(ext => name.endsWith(ext)); - }); - if (validFiles.length > 0) { - const dataTransfer = new DataTransfer(); - validFiles.forEach(f => dataTransfer.items.add(f)); - handleFileSelect(dataTransfer.files); - } - } - }); - - fileInput.addEventListener('click', () => { - fileInput.value = ''; - }); - } - - if (addMoreBtn) { - addMoreBtn.addEventListener('click', () => { - fileInput.click(); - }); - } - - if (clearFilesBtn) { - clearFilesBtn.addEventListener('click', () => { - resetState(); - }); - } - - if (processBtn) { - processBtn.addEventListener('click', convertToPdf); - } + if (processBtn) { + processBtn.addEventListener('click', convertToPdf); + } }); diff --git a/src/js/main.ts b/src/js/main.ts index d05df06..8474670 100644 --- a/src/js/main.ts +++ b/src/js/main.ts @@ -15,7 +15,7 @@ import { createLanguageSwitcher, t, } from './i18n/index.js'; -import { startBackgroundPreload } from './utils/wasm-preloader.js'; +declare const __BRAND_NAME__: string; const init = async () => { await initI18n(); @@ -82,18 +82,19 @@ const init = async () => { (divider as HTMLElement).style.display = 'none'; }); - document.title = 'BentoPDF - PDF Tools'; + const brandName = __BRAND_NAME__ || 'BentoPDF'; + document.title = `${brandName} - ${t('simpleMode.title')}`; const toolsHeader = document.getElementById('tools-header'); if (toolsHeader) { const title = toolsHeader.querySelector('h2'); const subtitle = toolsHeader.querySelector('p'); if (title) { - title.textContent = 'PDF Tools'; + title.textContent = t('simpleMode.title'); title.className = 'text-4xl md:text-5xl font-bold text-white mb-3'; } if (subtitle) { - subtitle.textContent = 'Select a tool to get started'; + subtitle.textContent = t('simpleMode.subtitle'); subtitle.className = 'text-lg text-gray-400'; } } @@ -137,6 +138,7 @@ const init = async () => { }; const toolTranslationKeys: Record = { + 'PDF Workflow Builder': 'tools:pdfWorkflow', 'PDF Multi Tool': 'tools:pdfMultiTool', 'Merge PDF': 'tools:mergePdf', 'Split PDF': 'tools:splitPdf', @@ -157,6 +159,7 @@ const init = async () => { 'Background Color': 'tools:backgroundColor', 'Change Text Color': 'tools:changeTextColor', 'Add Stamps': 'tools:addStamps', + 'Bates Numbering': 'tools:batesNumbering', 'Remove Annotations': 'tools:removeAnnotations', 'PDF Form Filler': 'tools:pdfFormFiller', 'Create PDF Form': 'tools:createPdfForm', @@ -211,25 +214,121 @@ const init = async () => { 'Deskew PDF': 'tools:deskewPdf', 'Digital Signature': 'tools:digitalSignPdf', 'Validate Signature': 'tools:validateSignaturePdf', + 'Scanner Effect': 'tools:scannerEffect', + 'Adjust Colors': 'tools:adjustColors', + 'Markdown to PDF': 'tools:markdownToPdf', + 'PDF Booklet': 'tools:pdfBooklet', + 'Word to PDF': 'tools:wordToPdf', + 'Excel to PDF': 'tools:excelToPdf', + 'PowerPoint to PDF': 'tools:powerpointToPdf', + 'XPS to PDF': 'tools:xpsToPdf', + 'MOBI to PDF': 'tools:mobiToPdf', + 'EPUB to PDF': 'tools:epubToPdf', + 'FB2 to PDF': 'tools:fb2ToPdf', + 'CBZ to PDF': 'tools:cbzToPdf', + 'WPD to PDF': 'tools:wpdToPdf', + 'WPS to PDF': 'tools:wpsToPdf', + 'XML to PDF': 'tools:xmlToPdf', + 'Pages to PDF': 'tools:pagesToPdf', + 'ODG to PDF': 'tools:odgToPdf', + 'ODS to PDF': 'tools:odsToPdf', + 'ODP to PDF': 'tools:odpToPdf', + 'PUB to PDF': 'tools:pubToPdf', + 'VSD to PDF': 'tools:vsdToPdf', + 'PSD to PDF': 'tools:psdToPdf', + 'ODT to PDF': 'tools:odtToPdf', + 'CSV to PDF': 'tools:csvToPdf', + 'RTF to PDF': 'tools:rtfToPdf', + 'PDF to SVG': 'tools:pdfToSvg', + 'PDF to CSV': 'tools:pdfToCsv', + 'PDF to Excel': 'tools:pdfToExcel', + 'PDF to Text': 'tools:pdfToText', + 'Extract Tables': 'tools:extractTables', + 'PDF to Word': 'tools:pdfToWord', + 'Extract Images': 'tools:extractImages', + 'PDF to Markdown': 'tools:pdfToMarkdown', + 'Prepare PDF for AI': 'tools:preparePdfForAi', + 'PDF OCG': 'tools:pdfOcg', + 'PDF to PDF/A': 'tools:pdfToPdfa', + 'Rasterize PDF': 'tools:rasterizePdf', }; // Homepage-only tool grid rendering (not used on individual tool pages) if (dom.toolGrid) { dom.toolGrid.textContent = ''; + let collapsedCategories: string[] = []; + try { + const stored = localStorage.getItem('collapsedCategories'); + if (stored) collapsedCategories = JSON.parse(stored); + } catch { + localStorage.removeItem('collapsedCategories'); + } + + function saveCollapsedCategories() { + localStorage.setItem( + 'collapsedCategories', + JSON.stringify(collapsedCategories) + ); + } + categories.forEach((category) => { const categoryGroup = document.createElement('div'); categoryGroup.className = 'category-group col-span-full'; - const title = document.createElement('h2'); - title.className = - 'text-xl font-bold text-indigo-400 mb-4 mt-8 first:mt-0 text-white'; + const header = document.createElement('button'); + header.className = 'category-header'; + header.type = 'button'; + + const title = document.createElement('span'); const categoryKey = categoryTranslationKeys[category.name]; title.textContent = categoryKey ? t(categoryKey) : category.name; + const chevron = document.createElement('i'); + chevron.setAttribute('data-lucide', 'chevron-down'); + chevron.className = + 'category-chevron w-5 h-5 text-gray-400 transition-transform duration-300'; + + header.append(title, chevron); + const toolsContainer = document.createElement('div'); toolsContainer.className = - 'grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4 md:gap-6'; + 'category-tools grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4 md:gap-6'; + + const isCollapsed = collapsedCategories.includes(category.name); + if (isCollapsed) { + categoryGroup.classList.add('collapsed'); + toolsContainer.style.maxHeight = '0px'; + } + + toolsContainer.addEventListener('transitionend', (e) => { + if ((e as TransitionEvent).propertyName !== 'max-height') return; + if (!categoryGroup.classList.contains('collapsed')) { + toolsContainer.style.maxHeight = 'none'; + toolsContainer.style.overflow = 'visible'; + } + }); + + header.addEventListener('click', () => { + const collapsed = categoryGroup.classList.toggle('collapsed'); + if (collapsed) { + toolsContainer.style.maxHeight = toolsContainer.scrollHeight + 'px'; + toolsContainer.style.overflow = 'hidden'; + requestAnimationFrame(() => { + toolsContainer.style.maxHeight = '0px'; + }); + if (!collapsedCategories.includes(category.name)) { + collapsedCategories.push(category.name); + } + } else { + toolsContainer.style.overflow = 'hidden'; + toolsContainer.style.maxHeight = toolsContainer.scrollHeight + 'px'; + collapsedCategories = collapsedCategories.filter( + (n) => n !== category.name + ); + } + saveCollapsedCategories(); + }); category.tools.forEach((tool) => { let toolCard: HTMLDivElement | HTMLAnchorElement; @@ -274,8 +373,13 @@ const init = async () => { toolsContainer.appendChild(toolCard); }); - categoryGroup.append(title, toolsContainer); + categoryGroup.append(header, toolsContainer); dom.toolGrid.appendChild(categoryGroup); + + if (!isCollapsed) { + toolsContainer.style.maxHeight = 'none'; + toolsContainer.style.overflow = 'visible'; + } }); const searchBar = document.getElementById('search-bar'); @@ -398,9 +502,6 @@ const init = async () => { createIcons({ icons }); console.log('Please share our tool and share the love!'); - // Start background WASM preloading on all pages - startBackgroundPreload(); - const githubStarsElements = [ document.getElementById('github-stars-desktop'), document.getElementById('github-stars-mobile'), @@ -512,6 +613,35 @@ const init = async () => { }); } + const compactModeToggle = document.getElementById( + 'compact-mode-toggle' + ) as HTMLInputElement; + + const savedCompactMode = localStorage.getItem('compactMode') === 'true'; + if (compactModeToggle) { + compactModeToggle.checked = savedCompactMode; + } + applyCompactMode(savedCompactMode); + + function applyCompactMode(enabled: boolean) { + if (dom.toolGrid) { + dom.toolGrid.classList.toggle('compact-mode', enabled); + dom.toolGrid + .querySelectorAll('.category-group:not(.collapsed) .category-tools') + .forEach((container) => { + (container as HTMLElement).style.maxHeight = 'none'; + }); + } + } + + if (compactModeToggle) { + compactModeToggle.addEventListener('change', (e) => { + const enabled = (e.target as HTMLInputElement).checked; + localStorage.setItem('compactMode', enabled.toString()); + applyCompactMode(enabled); + }); + } + // Shortcuts UI Handlers if (dom.openShortcutsBtn) { dom.openShortcutsBtn.addEventListener('click', () => { @@ -772,8 +902,12 @@ const init = async () => { left.className = 'flex items-center gap-3'; const icon = document.createElement('i'); - icon.className = 'w-5 h-5 text-indigo-400'; - icon.setAttribute('data-lucide', tool.icon); + if (tool.icon.startsWith('ph-')) { + icon.className = `ph ${tool.icon} w-5 h-5 text-indigo-400`; + } else { + icon.className = 'w-5 h-5 text-indigo-400'; + icon.setAttribute('data-lucide', tool.icon); + } const name = document.createElement('span'); name.className = 'text-gray-200 font-medium'; diff --git a/src/js/types/add-watermark-type.ts b/src/js/types/add-watermark-type.ts index 583d112..898d5c4 100644 --- a/src/js/types/add-watermark-type.ts +++ b/src/js/types/add-watermark-type.ts @@ -1,6 +1,26 @@ import { PDFDocument as PDFLibDocument } from 'pdf-lib'; export interface AddWatermarkState { - file: File | null; - pdfDoc: PDFLibDocument | null; -} \ No newline at end of file + file: File | null; + pdfDoc: PDFLibDocument | null; + pdfBytes: Uint8Array | null; + previewCanvas: HTMLCanvasElement | null; + watermarkX: number; // 0–1, percentage from left + watermarkY: number; // 0–1, percentage from top (flipped to bottom for PDF) +} + +export interface PageWatermarkConfig { + type: 'text' | 'image'; + x: number; + y: number; + text: string; + fontSize: number; + color: string; + opacityText: number; + angleText: number; + imageDataUrl: string | null; + imageFile: File | null; + imageScale: number; + opacityImage: number; + angleImage: number; +} diff --git a/src/js/types/adjust-colors-type.ts b/src/js/types/adjust-colors-type.ts new file mode 100644 index 0000000..ddcec5f --- /dev/null +++ b/src/js/types/adjust-colors-type.ts @@ -0,0 +1,10 @@ +export interface AdjustColorsSettings { + brightness: number; + contrast: number; + saturation: number; + hueShift: number; + temperature: number; + tint: number; + gamma: number; + sepia: number; +} diff --git a/src/js/types/bates-numbering-type.ts b/src/js/types/bates-numbering-type.ts new file mode 100644 index 0000000..8246dc3 --- /dev/null +++ b/src/js/types/bates-numbering-type.ts @@ -0,0 +1,17 @@ +export interface StylePreset { + template: string; + padding: number; +} + +export type Position = + | 'bottom-center' + | 'bottom-left' + | 'bottom-right' + | 'top-center' + | 'top-left' + | 'top-right'; + +export interface FileEntry { + file: File; + pageCount: number; +} diff --git a/src/js/types/compare-pdfs-type.ts b/src/js/types/compare-pdfs-type.ts index de54566..279e7a9 100644 --- a/src/js/types/compare-pdfs-type.ts +++ b/src/js/types/compare-pdfs-type.ts @@ -1,9 +1,9 @@ -import * as pdfjsLib from 'pdfjs-dist'; - -export interface CompareState { - pdfDoc1: pdfjsLib.PDFDocumentProxy | null; - pdfDoc2: pdfjsLib.PDFDocumentProxy | null; - currentPage: number; - viewMode: 'overlay' | 'side-by-side'; - isSyncScroll: boolean; -} +export type { + CompareState, + ComparePdfExportMode, + RenderedPage, + ComparisonPageLoad, + DiffFocusRegion, + CompareCaches, + CompareRenderContext, +} from '../compare/types.ts'; diff --git a/src/js/types/decrypt-pdf-type.ts b/src/js/types/decrypt-pdf-type.ts index 15ec438..053c177 100644 --- a/src/js/types/decrypt-pdf-type.ts +++ b/src/js/types/decrypt-pdf-type.ts @@ -1,3 +1,3 @@ export interface DecryptPdfState { - file: File | null; + files: File[]; } diff --git a/src/js/types/form-creator-type.ts b/src/js/types/form-creator-type.ts index f0f7c5b..9009d12 100644 --- a/src/js/types/form-creator-type.ts +++ b/src/js/types/form-creator-type.ts @@ -1,40 +1,93 @@ -export interface FormField { - id: string - type: 'text' | 'checkbox' | 'radio' | 'dropdown' | 'optionlist' | 'button' | 'signature' | 'date' | 'image' - x: number - y: number - width: number - height: number - name: string - defaultValue: string - fontSize: number - alignment: 'left' | 'center' | 'right' - textColor: string - required: boolean - readOnly: boolean - tooltip: string - combCells: number - maxLength: number - options?: string[] - checked?: boolean - exportValue?: string - groupName?: string - label?: string - pageIndex: number - action?: 'none' | 'reset' | 'print' | 'url' | 'js' | 'showHide' - actionUrl?: string - jsScript?: string - targetFieldName?: string - visibilityAction?: 'show' | 'hide' | 'toggle' - dateFormat?: string - multiline?: boolean - borderColor?: string - hideBorder?: boolean -} - -export interface PageData { - index: number - width: number - height: number - pdfPageData?: string -} +export const FORM_CREATOR_FIELD_TYPES = [ + 'text', + 'checkbox', + 'radio', + 'dropdown', + 'optionlist', + 'button', + 'signature', + 'date', + 'image', + 'barcode', +] as const; + +export type FormCreatorFieldType = (typeof FORM_CREATOR_FIELD_TYPES)[number]; + +export interface ExtractionViewportMetrics { + pdfViewerOffset: { + x: number; + y: number; + }; + pdfViewerScale: number; +} + +export interface ExtractExistingFieldsOptions { + pdfDoc: import('pdf-lib').PDFDocument; + fieldCounterStart: number; + metrics: ExtractionViewportMetrics; +} + +export interface ExtractExistingFieldsResult { + fields: FormField[]; + extractedFieldNames: Set; + nextFieldCounter: number; +} + +export interface ExtractedFieldLike { + type: 'text' | 'radio'; + name: string; + pageIndex: number; + x: number; + y: number; + width: number; + height: number; + tooltip: string; + required: boolean; + readOnly: boolean; + checked?: boolean; + exportValue?: string; + groupName?: string; +} + +export interface FormField { + id: string; + type: FormCreatorFieldType; + x: number; + y: number; + width: number; + height: number; + name: string; + defaultValue: string; + fontSize: number; + alignment: 'left' | 'center' | 'right'; + textColor: string; + required: boolean; + readOnly: boolean; + tooltip: string; + combCells: number; + maxLength: number; + options?: string[]; + checked?: boolean; + exportValue?: string; + groupName?: string; + label?: string; + pageIndex: number; + action?: 'none' | 'reset' | 'print' | 'url' | 'js' | 'showHide'; + actionUrl?: string; + jsScript?: string; + targetFieldName?: string; + visibilityAction?: 'show' | 'hide' | 'toggle'; + dateFormat?: string; + multiline?: boolean; + borderColor?: string; + hideBorder?: boolean; + barcodeFormat?: string; + barcodeValue?: string; +} + +export interface PageData { + index: number; + width: number; + height: number; + pdfPageData?: string; +} diff --git a/src/js/types/index.ts b/src/js/types/index.ts index 089a0ad..aa9dfe9 100644 --- a/src/js/types/index.ts +++ b/src/js/types/index.ts @@ -47,3 +47,7 @@ export * from './sign-pdf-type.ts'; export * from './add-watermark-type.ts'; export * from './email-to-pdf-type.ts'; export * from './bookmark-pdf-type.ts'; +export * from './scanner-effect-type.ts'; +export * from './adjust-colors-type.ts'; +export * from './bates-numbering-type.ts'; +export * from './page-preview-type.ts'; diff --git a/src/js/types/page-preview-type.ts b/src/js/types/page-preview-type.ts new file mode 100644 index 0000000..3e4f367 --- /dev/null +++ b/src/js/types/page-preview-type.ts @@ -0,0 +1,10 @@ +import { PDFDocumentProxy } from 'pdfjs-dist'; + +export interface PreviewState { + modal: HTMLElement | null; + pdfjsDoc: PDFDocumentProxy | null; + currentPage: number; + totalPages: number; + isOpen: boolean; + container: HTMLElement | null; +} diff --git a/src/js/types/scanner-effect-type.ts b/src/js/types/scanner-effect-type.ts new file mode 100644 index 0000000..c14b4d7 --- /dev/null +++ b/src/js/types/scanner-effect-type.ts @@ -0,0 +1,16 @@ +export interface ScannerEffectState { + file: File | null; +} + +export interface ScanSettings { + grayscale: boolean; + border: boolean; + rotate: number; + rotateVariance: number; + brightness: number; + contrast: number; + blur: number; + noise: number; + yellowish: number; + resolution: number; +} diff --git a/src/js/ui.ts b/src/js/ui.ts index 427dbdb..935459d 100644 --- a/src/js/ui.ts +++ b/src/js/ui.ts @@ -1,177 +1,201 @@ import { resetState } from './state.js'; import { formatBytes, getPDFDocument } from './utils/helpers.js'; import { tesseractLanguages } from './config/tesseract-languages.js'; -import { renderPagesProgressively, cleanupLazyRendering } from './utils/render-utils.js'; +import { + renderPagesProgressively, + cleanupLazyRendering, +} from './utils/render-utils.js'; +import { initPagePreview } from './utils/page-preview.js'; import { icons, createIcons } from 'lucide'; import Sortable from 'sortablejs'; -import { getRotationState, updateRotationState } from './utils/rotation-state.js'; +import { + getRotationState, + updateRotationState, +} from './utils/rotation-state.js'; import * as pdfjsLib from 'pdfjs-dist'; import { t } from './i18n/i18n'; -pdfjsLib.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).toString(); - +pdfjsLib.GlobalWorkerOptions.workerSrc = new URL( + 'pdfjs-dist/build/pdf.worker.min.mjs', + import.meta.url +).toString(); // Centralizing DOM element selection export const dom = { - gridView: document.getElementById('grid-view'), - toolGrid: document.getElementById('tool-grid'), - toolInterface: document.getElementById('tool-interface'), - toolContent: document.getElementById('tool-content'), - backToGridBtn: document.getElementById('back-to-grid'), - loaderModal: document.getElementById('loader-modal'), - loaderText: document.getElementById('loader-text'), - alertModal: document.getElementById('alert-modal'), - alertTitle: document.getElementById('alert-title'), - alertMessage: document.getElementById('alert-message'), - alertOkBtn: document.getElementById('alert-ok'), - heroSection: document.getElementById('hero-section'), - featuresSection: document.getElementById('features-section'), - toolsHeader: document.getElementById('tools-header'), - dividers: document.querySelectorAll('.section-divider'), - hideSections: document.querySelectorAll('.hide-section'), - shortcutsModal: document.getElementById('shortcuts-modal'), - closeShortcutsModalBtn: document.getElementById('close-shortcuts-modal'), - shortcutsList: document.getElementById('shortcuts-list'), - shortcutSearch: document.getElementById('shortcut-search'), - resetShortcutsBtn: document.getElementById('reset-shortcuts-btn'), - importShortcutsBtn: document.getElementById('import-shortcuts-btn'), - exportShortcutsBtn: document.getElementById('export-shortcuts-btn'), - openShortcutsBtn: document.getElementById('open-shortcuts-btn'), - warningModal: document.getElementById('warning-modal'), - warningTitle: document.getElementById('warning-title'), - warningMessage: document.getElementById('warning-message'), - warningCancelBtn: document.getElementById('warning-cancel-btn'), - warningConfirmBtn: document.getElementById('warning-confirm-btn'), + gridView: document.getElementById('grid-view'), + toolGrid: document.getElementById('tool-grid'), + toolInterface: document.getElementById('tool-interface'), + toolContent: document.getElementById('tool-content'), + backToGridBtn: document.getElementById('back-to-grid'), + loaderModal: document.getElementById('loader-modal'), + loaderText: document.getElementById('loader-text'), + alertModal: document.getElementById('alert-modal'), + alertTitle: document.getElementById('alert-title'), + alertMessage: document.getElementById('alert-message'), + alertOkBtn: document.getElementById('alert-ok'), + heroSection: document.getElementById('hero-section'), + featuresSection: document.getElementById('features-section'), + toolsHeader: document.getElementById('tools-header'), + dividers: document.querySelectorAll('.section-divider'), + hideSections: document.querySelectorAll('.hide-section'), + shortcutsModal: document.getElementById('shortcuts-modal'), + closeShortcutsModalBtn: document.getElementById('close-shortcuts-modal'), + shortcutsList: document.getElementById('shortcuts-list'), + shortcutSearch: document.getElementById('shortcut-search'), + resetShortcutsBtn: document.getElementById('reset-shortcuts-btn'), + importShortcutsBtn: document.getElementById('import-shortcuts-btn'), + exportShortcutsBtn: document.getElementById('export-shortcuts-btn'), + openShortcutsBtn: document.getElementById('open-shortcuts-btn'), + warningModal: document.getElementById('warning-modal'), + warningTitle: document.getElementById('warning-title'), + warningMessage: document.getElementById('warning-message'), + warningCancelBtn: document.getElementById('warning-cancel-btn'), + warningConfirmBtn: document.getElementById('warning-confirm-btn'), }; export const showLoader = (text = t('common.loading'), progress?: number) => { - if (dom.loaderText) dom.loaderText.textContent = text; + if (dom.loaderText) dom.loaderText.textContent = text; - // Add or update progress bar if progress is provided - const loaderModal = dom.loaderModal; - if (loaderModal) { - let progressBar = loaderModal.querySelector('.loader-progress-bar') as HTMLElement; - let progressContainer = loaderModal.querySelector('.loader-progress-container') as HTMLElement; + // Add or update progress bar if progress is provided + const loaderModal = dom.loaderModal; + if (loaderModal) { + let progressBar = loaderModal.querySelector( + '.loader-progress-bar' + ) as HTMLElement; + let progressContainer = loaderModal.querySelector( + '.loader-progress-container' + ) as HTMLElement; - if (progress !== undefined && progress >= 0) { - // Create progress container if it doesn't exist - if (!progressContainer) { - progressContainer = document.createElement('div'); - progressContainer.className = 'loader-progress-container w-64 mt-4'; - progressContainer.innerHTML = ` + if (progress !== undefined && progress >= 0) { + // Create progress container if it doesn't exist + if (!progressContainer) { + progressContainer = document.createElement('div'); + progressContainer.className = 'loader-progress-container w-64 mt-4'; + progressContainer.innerHTML = `

0%

`; - loaderModal.querySelector('.bg-gray-800')?.appendChild(progressContainer); - progressBar = progressContainer.querySelector('.loader-progress-bar') as HTMLElement; - } + loaderModal + .querySelector('.bg-gray-800') + ?.appendChild(progressContainer); + progressBar = progressContainer.querySelector( + '.loader-progress-bar' + ) as HTMLElement; + } - // Update progress - if (progressBar) { - progressBar.style.width = `${progress}%`; - } - const progressText = progressContainer.querySelector('.loader-progress-text'); - if (progressText) { - progressText.textContent = `${Math.round(progress)}%`; - } - progressContainer.classList.remove('hidden'); - } else { - // Hide progress bar if no progress provided - if (progressContainer) { - progressContainer.classList.add('hidden'); - } - } - - loaderModal.classList.remove('hidden'); + // Update progress + if (progressBar) { + progressBar.style.width = `${progress}%`; + } + const progressText = progressContainer.querySelector( + '.loader-progress-text' + ); + if (progressText) { + progressText.textContent = `${Math.round(progress)}%`; + } + progressContainer.classList.remove('hidden'); + } else { + // Hide progress bar if no progress provided + if (progressContainer) { + progressContainer.classList.add('hidden'); + } } + + loaderModal.classList.remove('hidden'); + } }; export const hideLoader = () => { - if (dom.loaderModal) dom.loaderModal.classList.add('hidden'); + if (dom.loaderModal) dom.loaderModal.classList.add('hidden'); }; -export const showAlert = (title: any, message: any, type: string = 'error', callback?: () => void) => { - if (dom.alertTitle) dom.alertTitle.textContent = title; - if (dom.alertMessage) dom.alertMessage.textContent = message; - if (dom.alertModal) dom.alertModal.classList.remove('hidden'); +export const showAlert = ( + title: any, + message: any, + type: string = 'error', + callback?: () => void +) => { + if (dom.alertTitle) dom.alertTitle.textContent = title; + if (dom.alertMessage) dom.alertMessage.textContent = message; + if (dom.alertModal) dom.alertModal.classList.remove('hidden'); - if (dom.alertOkBtn) { - const newOkBtn = dom.alertOkBtn.cloneNode(true) as HTMLElement; - dom.alertOkBtn.replaceWith(newOkBtn); - dom.alertOkBtn = newOkBtn; + if (dom.alertOkBtn) { + const newOkBtn = dom.alertOkBtn.cloneNode(true) as HTMLElement; + dom.alertOkBtn.replaceWith(newOkBtn); + dom.alertOkBtn = newOkBtn; - newOkBtn.addEventListener('click', () => { - hideAlert(); - if (callback) callback(); - }); - } + newOkBtn.addEventListener('click', () => { + hideAlert(); + if (callback) callback(); + }); + } }; export const hideAlert = () => { - if (dom.alertModal) dom.alertModal.classList.add('hidden'); + if (dom.alertModal) dom.alertModal.classList.add('hidden'); }; export const switchView = (view: any) => { - if (view === 'grid') { - dom.gridView.classList.remove('hidden'); - dom.toolInterface.classList.add('hidden'); - // show hero and features and header - dom.heroSection.classList.remove('hidden'); - dom.featuresSection.classList.remove('hidden'); - dom.toolsHeader.classList.remove('hidden'); - // show dividers - dom.dividers.forEach((divider) => { - divider.classList.remove('hidden'); - }); - // show hideSections - dom.hideSections.forEach((section) => { - section.classList.remove('hidden'); - }); + if (view === 'grid') { + dom.gridView.classList.remove('hidden'); + dom.toolInterface.classList.add('hidden'); + // show hero and features and header + dom.heroSection.classList.remove('hidden'); + dom.featuresSection.classList.remove('hidden'); + dom.toolsHeader.classList.remove('hidden'); + // show dividers + dom.dividers.forEach((divider) => { + divider.classList.remove('hidden'); + }); + // show hideSections + dom.hideSections.forEach((section) => { + section.classList.remove('hidden'); + }); - resetState(); - } else { - dom.gridView.classList.add('hidden'); - dom.toolInterface.classList.remove('hidden'); - dom.featuresSection.classList.add('hidden'); - dom.heroSection.classList.add('hidden'); - dom.toolsHeader.classList.add('hidden'); - dom.dividers.forEach((divider) => { - divider.classList.add('hidden'); - }); - dom.hideSections.forEach((section) => { - section.classList.add('hidden'); - }); - } + resetState(); + } else { + dom.gridView.classList.add('hidden'); + dom.toolInterface.classList.remove('hidden'); + dom.featuresSection.classList.add('hidden'); + dom.heroSection.classList.add('hidden'); + dom.toolsHeader.classList.add('hidden'); + dom.dividers.forEach((divider) => { + divider.classList.add('hidden'); + }); + dom.hideSections.forEach((section) => { + section.classList.add('hidden'); + }); + } }; const thumbnailState = { - sortableInstances: {}, + sortableInstances: {}, }; function initializeOrganizeSortable(containerId: any) { - const container = document.getElementById(containerId); - if (!container) return; + const container = document.getElementById(containerId); + if (!container) return; - if (thumbnailState.sortableInstances[containerId]) { - thumbnailState.sortableInstances[containerId].destroy(); - } + if (thumbnailState.sortableInstances[containerId]) { + thumbnailState.sortableInstances[containerId].destroy(); + } - thumbnailState.sortableInstances[containerId] = Sortable.create(container, { - animation: 150, - ghostClass: 'sortable-ghost', - chosenClass: 'sortable-chosen', - dragClass: 'sortable-drag', - filter: '.delete-page-btn', - preventOnFilter: true, - onStart: function (evt: any) { - evt.item.style.opacity = '0.5'; - }, - onEnd: function (evt: any) { - evt.item.style.opacity = '1'; - }, - }); + thumbnailState.sortableInstances[containerId] = Sortable.create(container, { + animation: 150, + ghostClass: 'sortable-ghost', + chosenClass: 'sortable-chosen', + dragClass: 'sortable-drag', + filter: '.delete-page-btn', + preventOnFilter: true, + onStart: function (evt: any) { + evt.item.style.opacity = '0.5'; + }, + onEnd: function (evt: any) { + evt.item.style.opacity = '1'; + }, + }); } /** @@ -180,243 +204,247 @@ function initializeOrganizeSortable(containerId: any) { * @param {object} pdfDoc The loaded pdf-lib document instance. */ export const renderPageThumbnails = async (toolId: any, pdfDoc: any) => { - const containerId = toolId === 'organize' ? 'page-organizer' : toolId === 'delete-pages' ? 'delete-pages-preview' : 'page-rotator'; - const container = document.getElementById(containerId); - if (!container) return; + const containerId = + toolId === 'organize' + ? 'page-organizer' + : toolId === 'delete-pages' + ? 'delete-pages-preview' + : 'page-rotator'; + const container = document.getElementById(containerId); + if (!container) return; - container.innerHTML = ''; + container.innerHTML = ''; - // Cleanup any previous lazy loading observers - cleanupLazyRendering(); + // Cleanup any previous lazy loading observers + cleanupLazyRendering(); - const currentRenderId = Date.now(); - container.dataset.renderId = currentRenderId.toString(); + const currentRenderId = Date.now(); + container.dataset.renderId = currentRenderId.toString(); - showLoader(t('multiTool.renderingTitle')); + showLoader(t('multiTool.renderingTitle')); - const pdfData = await pdfDoc.save(); - const pdf = await getPDFDocument({ data: pdfData }).promise; + const pdfData = await pdfDoc.save(); + const pdf = await getPDFDocument({ data: pdfData }).promise; - // Function to create wrapper element for each page - const createWrapper = (canvas: HTMLCanvasElement, pageNumber: number) => { - const wrapper = document.createElement('div'); - // @ts-expect-error TS(2322) FIXME: Type 'number' is not assignable to type 'string'. - wrapper.dataset.pageIndex = pageNumber - 1; + // Function to create wrapper element for each page + const createWrapper = (canvas: HTMLCanvasElement, pageNumber: number) => { + const wrapper = document.createElement('div'); + // @ts-expect-error TS(2322) FIXME: Type 'number' is not assignable to type 'string'. + wrapper.dataset.pageIndex = pageNumber - 1; - const imgContainer = document.createElement('div'); - imgContainer.className = - 'w-full h-36 bg-gray-900 rounded-lg flex items-center justify-center overflow-hidden border-2 border-gray-600'; + const imgContainer = document.createElement('div'); + imgContainer.className = 'relative'; - const img = document.createElement('img'); - img.src = canvas.toDataURL(); - img.className = 'max-w-full max-h-full object-contain'; + const img = document.createElement('img'); + img.src = canvas.toDataURL(); + img.className = 'rounded-md shadow-md max-w-full h-auto'; - imgContainer.appendChild(img); + imgContainer.appendChild(img); - if (toolId === 'organize') { - wrapper.className = 'page-thumbnail relative group'; - wrapper.appendChild(imgContainer); + const pageNumSpan = document.createElement('div'); + pageNumSpan.className = + 'absolute top-1 left-1 bg-indigo-600 text-white text-xs px-2 py-1 rounded-md font-semibold shadow-lg z-10 pointer-events-none'; + pageNumSpan.textContent = pageNumber.toString(); - const pageNumSpan = document.createElement('span'); - pageNumSpan.className = - 'absolute top-1 left-1 bg-gray-900 bg-opacity-75 text-white text-xs rounded-full px-2 py-1'; - pageNumSpan.textContent = pageNumber.toString(); + if (toolId === 'organize') { + wrapper.className = + 'page-thumbnail relative cursor-move flex flex-col items-center gap-1 p-2 border-2 border-gray-600 hover:border-indigo-500 rounded-lg bg-gray-700 transition-colors group'; - const deleteBtn = document.createElement('button'); - deleteBtn.className = - 'delete-page-btn absolute top-1 right-1 bg-red-600 text-white rounded-full w-6 h-6 flex items-center justify-center'; - deleteBtn.innerHTML = '×'; - deleteBtn.addEventListener('click', (e) => { - (e.currentTarget as HTMLElement).parentElement.remove(); + imgContainer.appendChild(pageNumSpan); + wrapper.appendChild(imgContainer); - // Renumber remaining pages - const pages = container.querySelectorAll('.page-thumbnail'); - pages.forEach((page, index) => { - const numSpan = page.querySelector('span'); - if (numSpan) { - numSpan.textContent = (index + 1).toString(); - } - }); + const deleteBtn = document.createElement('button'); + deleteBtn.className = + 'delete-page-btn absolute top-1 right-1 bg-red-600 hover:bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center z-10'; + deleteBtn.innerHTML = '×'; + deleteBtn.addEventListener('click', (e) => { + (e.currentTarget as HTMLElement).parentElement.remove(); - initializeOrganizeSortable(containerId); - }); + // Renumber remaining pages + const pages = container.querySelectorAll('.page-thumbnail'); + pages.forEach((page, index) => { + const numSpan = page.querySelector('.bg-indigo-600'); + if (numSpan) { + numSpan.textContent = (index + 1).toString(); + } + }); - wrapper.append(pageNumSpan, deleteBtn); - } else if (toolId === 'rotate') { - wrapper.className = 'page-rotator-item flex flex-col items-center gap-2 relative group'; + initializeOrganizeSortable(containerId); + }); - // Read rotation from state (handles "Rotate All" on lazy-loaded pages) - const rotationStateArray = getRotationState(); - const pageIndex = pageNumber - 1; - const initialRotation = rotationStateArray[pageIndex] || 0; + wrapper.appendChild(deleteBtn); + } else if (toolId === 'rotate') { + wrapper.className = + 'page-rotator-item flex flex-col items-center gap-2 p-2 border-2 border-gray-600 hover:border-indigo-500 rounded-lg bg-gray-700 transition-colors relative group'; - wrapper.dataset.rotation = initialRotation.toString(); - img.classList.add('transition-transform', 'duration-300'); + // Read rotation from state (handles "Rotate All" on lazy-loaded pages) + const rotationStateArray = getRotationState(); + const pageIndex = pageNumber - 1; + const initialRotation = rotationStateArray[pageIndex] || 0; - // Apply initial rotation if any - if (initialRotation !== 0) { - img.style.transform = `rotate(${initialRotation}deg)`; - } + wrapper.dataset.rotation = initialRotation.toString(); + img.classList.add('transition-transform', 'duration-300'); - wrapper.appendChild(imgContainer); + // Apply initial rotation if any + if (initialRotation !== 0) { + img.style.transform = `rotate(${initialRotation}deg)`; + } - // Page Number Overlay (Top Left) - const pageNumSpan = document.createElement('span'); - pageNumSpan.className = - 'absolute top-2 left-2 bg-gray-900 bg-opacity-75 text-white text-xs font-medium rounded-md px-2 py-1 shadow-sm z-10 pointer-events-none'; - pageNumSpan.textContent = pageNumber.toString(); - wrapper.appendChild(pageNumSpan); + imgContainer.appendChild(pageNumSpan); + wrapper.appendChild(imgContainer); - const controlsDiv = document.createElement('div'); - controlsDiv.className = 'flex flex-col lg:flex-row items-center justify-center w-full gap-2 px-1'; + const controlsDiv = document.createElement('div'); + controlsDiv.className = + 'flex flex-col lg:flex-row items-center justify-center w-full gap-2 px-1'; - // Custom Stepper Component - const stepperContainer = document.createElement('div'); - stepperContainer.className = 'flex items-center border border-gray-600 rounded-md bg-gray-800 overflow-hidden w-24 h-8'; + // Custom Stepper Component + const stepperContainer = document.createElement('div'); + stepperContainer.className = + 'flex items-center border border-gray-600 rounded-md bg-gray-800 overflow-hidden w-24 h-8'; - const decrementBtn = document.createElement('button'); - decrementBtn.className = 'px-2 h-full text-gray-400 hover:text-white hover:bg-gray-700 border-r border-gray-600 transition-colors flex items-center justify-center'; - decrementBtn.innerHTML = ''; + const decrementBtn = document.createElement('button'); + decrementBtn.className = + 'px-2 h-full text-gray-400 hover:text-white hover:bg-gray-700 border-r border-gray-600 transition-colors flex items-center justify-center'; + decrementBtn.innerHTML = ''; - const angleInput = document.createElement('input'); - angleInput.type = 'number'; - angleInput.className = 'no-spinner w-full h-full bg-transparent text-white text-xs text-center focus:outline-none appearance-none m-0 p-0 border-none'; - angleInput.value = initialRotation.toString(); - angleInput.placeholder = "0"; + const angleInput = document.createElement('input'); + angleInput.type = 'number'; + angleInput.className = + 'no-spinner w-full h-full bg-transparent text-white text-xs text-center focus:outline-none appearance-none m-0 p-0 border-none'; + angleInput.value = initialRotation.toString(); + angleInput.placeholder = '0'; - const incrementBtn = document.createElement('button'); - incrementBtn.className = 'px-2 h-full text-gray-400 hover:text-white hover:bg-gray-700 border-l border-gray-600 transition-colors flex items-center justify-center'; - incrementBtn.innerHTML = ''; + const incrementBtn = document.createElement('button'); + incrementBtn.className = + 'px-2 h-full text-gray-400 hover:text-white hover:bg-gray-700 border-l border-gray-600 transition-colors flex items-center justify-center'; + incrementBtn.innerHTML = ''; - // Helper to update rotation - const updateRotation = (newRotation: number) => { - const card = wrapper; // Closure capture - const imgEl = card.querySelector('img'); - const pageIndex = pageNumber - 1; + // Helper to update rotation + const updateRotation = (newRotation: number) => { + const card = wrapper; // Closure capture + const imgEl = card.querySelector('img'); + const pageIndex = pageNumber - 1; - // Update UI - angleInput.value = newRotation.toString(); - card.dataset.rotation = newRotation.toString(); - imgEl.style.transform = `rotate(${newRotation}deg)`; + // Update UI + angleInput.value = newRotation.toString(); + card.dataset.rotation = newRotation.toString(); + imgEl.style.transform = `rotate(${newRotation}deg)`; - // Update State - updateRotationState(pageIndex, newRotation); - }; + // Update State + updateRotationState(pageIndex, newRotation); + }; - // Event Listeners - decrementBtn.addEventListener('click', (e) => { - e.stopPropagation(); - const current = parseInt(angleInput.value) || 0; - updateRotation(current - 1); - }); + // Event Listeners + decrementBtn.addEventListener('click', (e) => { + e.stopPropagation(); + const current = parseInt(angleInput.value) || 0; + updateRotation(current - 1); + }); - incrementBtn.addEventListener('click', (e) => { - e.stopPropagation(); - const current = parseInt(angleInput.value) || 0; - updateRotation(current + 1); - }); + incrementBtn.addEventListener('click', (e) => { + e.stopPropagation(); + const current = parseInt(angleInput.value) || 0; + updateRotation(current + 1); + }); - angleInput.addEventListener('change', (e) => { - e.stopPropagation(); - const val = parseInt((e.target as HTMLInputElement).value) || 0; - updateRotation(val); - }); - angleInput.addEventListener('click', (e) => e.stopPropagation()); + angleInput.addEventListener('change', (e) => { + e.stopPropagation(); + const val = parseInt((e.target as HTMLInputElement).value) || 0; + updateRotation(val); + }); + angleInput.addEventListener('click', (e) => e.stopPropagation()); - stepperContainer.append(decrementBtn, angleInput, incrementBtn); + stepperContainer.append(decrementBtn, angleInput, incrementBtn); - const rotateBtn = document.createElement('button'); - rotateBtn.className = 'rotate-btn btn bg-gray-700 hover:bg-gray-600 p-1.5 rounded-md text-gray-200 transition-colors flex-shrink-0'; - rotateBtn.title = 'Rotate +90°'; - rotateBtn.innerHTML = ''; - rotateBtn.addEventListener('click', (e) => { - e.stopPropagation(); - const current = parseInt(angleInput.value) || 0; - updateRotation(current + 90); - }); + const rotateBtn = document.createElement('button'); + rotateBtn.className = + 'rotate-btn btn bg-gray-700 hover:bg-gray-600 p-1.5 rounded-md text-gray-200 transition-colors flex-shrink-0'; + rotateBtn.title = 'Rotate +90°'; + rotateBtn.innerHTML = ''; + rotateBtn.addEventListener('click', (e) => { + e.stopPropagation(); + const current = parseInt(angleInput.value) || 0; + updateRotation(current + 90); + }); - controlsDiv.append(stepperContainer, rotateBtn); - wrapper.appendChild(controlsDiv); - } else if (toolId === 'delete-pages') { - wrapper.className = 'page-thumbnail relative group cursor-pointer transition-all duration-200'; - wrapper.dataset.pageNumber = pageNumber.toString(); + controlsDiv.append(stepperContainer, rotateBtn); + wrapper.appendChild(controlsDiv); + } else if (toolId === 'delete-pages') { + wrapper.className = + 'page-thumbnail relative cursor-pointer flex flex-col items-center gap-1 p-2 border-2 border-gray-600 hover:border-indigo-500 rounded-lg bg-gray-700 transition-colors group'; + wrapper.dataset.pageNumber = pageNumber.toString(); - const innerContainer = document.createElement('div'); - innerContainer.className = 'relative w-full h-36 bg-gray-900 rounded-lg flex items-center justify-center overflow-hidden border-2 border-gray-600 transition-colors duration-200'; - innerContainer.appendChild(img); - wrapper.appendChild(innerContainer); + imgContainer.appendChild(pageNumSpan); + wrapper.appendChild(imgContainer); - const pageNumSpan = document.createElement('span'); - pageNumSpan.className = - 'absolute top-2 left-2 bg-gray-900 bg-opacity-75 text-white text-xs font-medium rounded-md px-2 py-1 shadow-sm z-10 pointer-events-none'; - pageNumSpan.textContent = pageNumber.toString(); - wrapper.appendChild(pageNumSpan); + wrapper.addEventListener('click', () => { + const input = document.getElementById( + 'pages-to-delete' + ) as HTMLInputElement; + if (!input) return; - wrapper.addEventListener('click', () => { - const input = document.getElementById('pages-to-delete') as HTMLInputElement; - if (!input) return; + const currentVal = input.value; + let pages = currentVal + .split(',') + .map((s) => s.trim()) + .filter((s) => s); + const pageStr = pageNumber.toString(); - const currentVal = input.value; - let pages = currentVal.split(',').map(s => s.trim()).filter(s => s); - const pageStr = pageNumber.toString(); - - if (pages.includes(pageStr)) { - pages = pages.filter(p => p !== pageStr); - } else { - pages.push(pageStr); - } - - pages.sort((a, b) => { - const numA = parseInt(a.split('-')[0]); - const numB = parseInt(b.split('-')[0]); - return numA - numB; - }); - - input.value = pages.join(', '); - - input.dispatchEvent(new Event('input')); - }); + if (pages.includes(pageStr)) { + pages = pages.filter((p) => p !== pageStr); + } else { + pages.push(pageStr); } - return wrapper; - }; + pages.sort((a, b) => { + const numA = parseInt(a.split('-')[0]); + const numB = parseInt(b.split('-')[0]); + return numA - numB; + }); - try { - // Render pages progressively with lazy loading - await renderPagesProgressively( - pdf, - container, - createWrapper, - { - batchSize: 8, - useLazyLoading: true, - lazyLoadMargin: '300px', - onProgress: (current, total) => { - showLoader(`Rendering page previews: ${current}/${total}`); - }, - onBatchComplete: () => { - createIcons({ icons }); - }, - shouldCancel: () => { - return container.dataset.renderId !== currentRenderId.toString(); - } - } - ); + input.value = pages.join(', '); - if (toolId === 'organize') { - initializeOrganizeSortable(containerId); - } else if (toolId === 'delete-pages') { - // No sortable needed for delete pages - } - - // Reinitialize lucide icons for dynamically added elements - createIcons({ icons }); - } catch (error) { - console.error('Error rendering page thumbnails:', error); - showAlert(t('multiTool.error'), t('multiTool.errorRendering')); - } finally { - hideLoader(); + input.dispatchEvent(new Event('input')); + }); } + + return wrapper; + }; + + try { + // Render pages progressively with lazy loading + await renderPagesProgressively(pdf, container, createWrapper, { + batchSize: 8, + useLazyLoading: true, + lazyLoadMargin: '300px', + onProgress: (current, total) => { + showLoader(`Rendering page previews: ${current}/${total}`); + }, + onBatchComplete: () => { + createIcons({ icons }); + }, + shouldCancel: () => { + return container.dataset.renderId !== currentRenderId.toString(); + }, + }); + + if (toolId === 'organize') { + initializeOrganizeSortable(containerId); + } else if (toolId === 'delete-pages') { + // No sortable needed for delete pages + } + + // Reinitialize lucide icons for dynamically added elements + createIcons({ icons }); + + // Attach Quick Look page preview + initPagePreview(container, pdf); + } catch (error) { + console.error('Error rendering page thumbnails:', error); + showAlert(t('multiTool.error'), t('multiTool.errorRendering')); + } finally { + hideLoader(); + } }; /** @@ -425,36 +453,36 @@ export const renderPageThumbnails = async (toolId: any, pdfDoc: any) => { * @param {File[]} files The array of file objects. */ export const renderFileDisplay = (container: any, files: any) => { - container.textContent = ''; - if (files.length > 0) { - files.forEach((file: any) => { - const fileDiv = document.createElement('div'); - fileDiv.className = - 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm'; + container.textContent = ''; + if (files.length > 0) { + files.forEach((file: any) => { + const fileDiv = document.createElement('div'); + fileDiv.className = + 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm'; - const nameSpan = document.createElement('span'); - nameSpan.className = 'truncate font-medium text-gray-200'; - nameSpan.textContent = file.name; + const nameSpan = document.createElement('span'); + nameSpan.className = 'truncate font-medium text-gray-200'; + nameSpan.textContent = file.name; - const sizeSpan = document.createElement('span'); - sizeSpan.className = 'flex-shrink-0 ml-4 text-gray-400'; - sizeSpan.textContent = formatBytes(file.size); + const sizeSpan = document.createElement('span'); + sizeSpan.className = 'flex-shrink-0 ml-4 text-gray-400'; + sizeSpan.textContent = formatBytes(file.size); - fileDiv.append(nameSpan, sizeSpan); - container.appendChild(fileDiv); - }); - } + fileDiv.append(nameSpan, sizeSpan); + container.appendChild(fileDiv); + }); + } }; const createFileInputHTML = (options = {}) => { - // @ts-expect-error TS(2339) FIXME: Property 'multiple' does not exist on type '{}'. - const multiple = options.multiple ? 'multiple' : ''; - // @ts-expect-error TS(2339) FIXME: Property 'accept' does not exist on type '{}'. - const acceptedFiles = options.accept || 'application/pdf'; - // @ts-expect-error TS(2339) FIXME: Property 'showControls' does not exist on type '{}... Remove this comment to see the full error message - const showControls = options.showControls || false; // NEW: Add this parameter + // @ts-expect-error TS(2339) FIXME: Property 'multiple' does not exist on type '{}'. + const multiple = options.multiple ? 'multiple' : ''; + // @ts-expect-error TS(2339) FIXME: Property 'accept' does not exist on type '{}'. + const acceptedFiles = options.accept || 'application/pdf'; + // @ts-expect-error TS(2339) FIXME: Property 'showControls' does not exist on type '{}... Remove this comment to see the full error message + const showControls = options.showControls || false; // NEW: Add this parameter - return ` + return `
@@ -465,7 +493,8 @@ const createFileInputHTML = (options = {}) => {
- ${showControls + ${ + showControls ? `

Process

-

Click the process button to start

+

+ Click the process button to start +

@@ -326,7 +333,9 @@

Download

-

Save your processed file instantly

+

+ Save your processed file instantly +

@@ -334,7 +343,10 @@
-

+

Related PDF Tools

@@ -378,7 +390,10 @@
-

+

Frequently Asked Questions

diff --git a/src/pages/add-blank-page.html b/src/pages/add-blank-page.html index c41831d..b9edd49 100644 --- a/src/pages/add-blank-page.html +++ b/src/pages/add-blank-page.html @@ -126,10 +126,10 @@ class="text-2xl font-bold text-white mb-2" data-i18n="tools:addBlankPage.name" > - Add Blank Page Free Online - Fast & Secure + Add Blank Page

- Insert one or more blank pages at any position in your PDF document. + Insert an empty page anywhere in your PDF.

or drag and drop

-

A single PDF file

+

+ A single PDF file +

Your files never leave your device.

@@ -275,7 +277,10 @@
-

+

How It Works

@@ -287,7 +292,7 @@

Upload File

-

+

Click or drag and drop your file to begin

@@ -300,7 +305,9 @@

Process

-

Click the process button to start

+

+ Click the process button to start +

@@ -311,7 +318,9 @@

Download

-

Save your processed file instantly

+

+ Save your processed file instantly +

@@ -319,7 +328,10 @@
-

+

Related PDF Tools

@@ -363,7 +375,10 @@
-

+

Frequently Asked Questions

diff --git a/src/pages/add-stamps.html b/src/pages/add-stamps.html index f8e3432..08bbf9c 100644 --- a/src/pages/add-stamps.html +++ b/src/pages/add-stamps.html @@ -116,11 +116,10 @@ class="text-2xl font-bold text-white mb-2" data-i18n="tools:addStamps.name" > - Add Stamps Free Online - Fast & Secure + Add Stamps

- Upload a PDF, add image stamps using the toolbar, and then save the - stamped PDF. + Add image stamps to your PDF using the annotation toolbar.

@@ -160,7 +159,9 @@ > or drag and drop

-

PDF file

+

+ PDF file +

Your files never leave your device.

@@ -207,7 +208,10 @@
-

+

How It Works

@@ -219,7 +223,7 @@

Upload File

-

+

Click or drag and drop your file to begin

@@ -232,7 +236,9 @@

Process

-

Click the process button to start

+

+ Click the process button to start +

@@ -243,7 +249,9 @@

Download

-

Save your processed file instantly

+

+ Save your processed file instantly +

@@ -251,7 +259,10 @@
-

+

Related PDF Tools

@@ -295,7 +306,10 @@
-

+

Frequently Asked Questions

diff --git a/src/pages/add-watermark.html b/src/pages/add-watermark.html index 90f2def..81b0db5 100644 --- a/src/pages/add-watermark.html +++ b/src/pages/add-watermark.html @@ -120,11 +120,10 @@ class="text-2xl font-bold text-white mb-2" data-i18n="tools:addWatermark.name" > - Add Watermark to PDF Free - Protect PDFs + Add Watermark

- Add text or image watermarks to your PDF documents with customizable - opacity and rotation. + Stamp text or an image over your PDF pages.

or drag and drop

-

PDF file

+

+ PDF file +

Your files never leave your device.

@@ -156,171 +157,406 @@
+
+
- @@ -415,7 +658,10 @@
-

+

Related PDF Tools

@@ -459,7 +705,10 @@
-

+

Frequently Asked Questions

diff --git a/src/pages/adjust-colors.html b/src/pages/adjust-colors.html new file mode 100644 index 0000000..b9f7dbc --- /dev/null +++ b/src/pages/adjust-colors.html @@ -0,0 +1,610 @@ + + + + + + + + Adjust Colors Online Free - PDF Color Adjustment Tool | BentoPDF + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Adjust Colors - BentoPDF + + + + + + + + + + + + + + + {{> navbar }} + +
+
+ + +

+ Adjust Colors +

+

+ Fine-tune brightness, contrast, saturation and more in your PDF. +

+ +
+
+ +

+ Click to select a file + or drag and drop +

+

+ A single PDF file +

+

+ Your files never leave your device. +

+
+ +
+ +
+ + +
+
+ + + + + +
+

+ How It Works +

+
+
+
+ 1 +
+
+

Upload File

+

+ Click or drag and drop your PDF file to begin +

+
+
+
+
+ 2 +
+
+

Adjust Colors

+

+ Fine-tune color settings with real-time preview +

+
+
+
+
+ 3 +
+
+

Download

+

Save your color-adjusted PDF instantly

+
+
+
+
+ +
+

+ Related PDF Tools +

+ +
+ +
+

+ Frequently Asked Questions +

+
+
+ + Is adjust colors really free? + + +

+ Yes! BentoPDF is 100% free with no hidden fees, no signup required, + and unlimited file processing. +

+
+
+ + Are my files private and secure? + + +

+ Absolutely! All processing happens in your browser. Your files never + leave your device, ensuring complete privacy. +

+
+
+ + Is there a file size limit? + + +

+ No! Process files of any size, as many times as you want, completely + free. +

+
+
+
+ {{> footer }} + + + + + + + + + + + + + + + diff --git a/src/pages/alternate-merge.html b/src/pages/alternate-merge.html index dd81fcd..be994b8 100644 --- a/src/pages/alternate-merge.html +++ b/src/pages/alternate-merge.html @@ -132,11 +132,10 @@ class="text-2xl font-bold text-white mb-2" data-i18n="tools:alternateMix.name" > - Alternate Merge Free Online - Combine PDFs Instantly + Alternate & Mix Pages

- Upload multiple PDFs and interleave their pages. Drag to reorder the - files, then mix their pages alternately. + Merge PDFs by alternating pages from each PDF. Preserves Bookmarks.

- Click to select files + Click to select files or drag and drop

-

Multiple PDF files (at least 2)

+

+ Multiple PDF files (at least 2) +

Your files never leave your device.

@@ -231,7 +237,10 @@
-

+

How It Works

@@ -243,7 +252,7 @@

Upload File

-

+

Click or drag and drop your file to begin

@@ -256,7 +265,9 @@

Process

-

Click the process button to start

+

+ Click the process button to start +

@@ -267,7 +278,9 @@

Download

-

Save your processed file instantly

+

+ Save your processed file instantly +

@@ -275,7 +288,10 @@
-

+

Related PDF Tools

@@ -319,7 +335,10 @@
-

+

Frequently Asked Questions

diff --git a/src/pages/background-color.html b/src/pages/background-color.html index 545f5fd..e7d0338 100644 --- a/src/pages/background-color.html +++ b/src/pages/background-color.html @@ -134,13 +134,13 @@ class="text-2xl font-bold text-white mb-2" data-i18n="tools:backgroundColor.name" > - Background Color Free Online - Fast & Secure + Background Color

- Add or change the background color of all pages in your PDF document. + Change the background color of your PDF.

or drag and drop

-

PDF file

+

+ PDF file +

Your files never leave your device.

@@ -180,12 +182,7 @@ class="block mb-2 text-sm font-medium text-gray-300" >Background Color - +

Process

-

Click the process button to start

+

+ Click the process button to start +

@@ -276,7 +278,9 @@

Download

-

Save your processed file instantly

+

+ Save your processed file instantly +

@@ -284,7 +288,10 @@
-

+

Related PDF Tools

@@ -328,7 +335,10 @@
-

+

Frequently Asked Questions

diff --git a/src/pages/bates-numbering.html b/src/pages/bates-numbering.html new file mode 100644 index 0000000..0b7c881 --- /dev/null +++ b/src/pages/bates-numbering.html @@ -0,0 +1,543 @@ + + + + + + + Bates Numbering Online Free - Add Bates Stamps | BentoPDF + + + + + + + + + + + + + + + + + + + + + + + + + + + + {{> navbar }} + +
+
+ + +

+ Bates Numbering +

+

+ Add sequential Bates numbers across one or more PDF files. +

+ +
+
+ +

+ Click to select files + or drag and drop +

+

PDF files (multiple supported)

+

+ Your files never leave your device. +

+
+ +
+ + + +
+ + +
+
+ + + + + +
+

+ How It Works +

+
+
+
+ 1 +
+
+

Upload Files

+

+ Upload one or more PDF files. Drag to reorder for multi-file + sequencing. +

+
+
+
+
+ 2 +
+
+

+ Configure Style +

+

+ Choose a preset or create a custom template with placeholders like + [BATES], [PAGE], [FILE]. +

+
+
+
+
+ 3 +
+
+

Download

+

+ Get your bates-stamped PDFs instantly. Multiple files are + delivered as a ZIP. +

+
+
+
+
+ +
+

+ Related PDF Tools +

+ +
+ +
+

+ Frequently Asked Questions +

+
+
+ + What is Bates numbering? + + +

+ Bates numbering is a method of indexing legal documents for easy + identification and retrieval. Each page receives a unique sequential + number, often combined with a prefix or suffix. +

+
+
+ + Can I number multiple files at once? + + +

+ Yes! Upload multiple PDFs and the bates numbers will continue + sequentially across all files. Drag files to reorder them. +

+
+
+ + Are my files secure? + + +

+ All processing happens entirely in your browser. Your files never + leave your device. +

+
+
+
+ + {{> footer }} + + + + + + + + + + + diff --git a/src/pages/bmp-to-pdf.html b/src/pages/bmp-to-pdf.html index 3bc338a..105bee2 100644 --- a/src/pages/bmp-to-pdf.html +++ b/src/pages/bmp-to-pdf.html @@ -120,10 +120,10 @@ class="text-2xl font-bold text-white mb-2" data-i18n="tools:bmpToPdf.name" > - Bmp To Pdf Converter Free Online - Convert Files Fast + BMP to PDF

- Convert one or more BMP images into a single PDF file. + Create a PDF from one or more BMP images.

- Click to select files + Click to select files or drag and drop

-

BMP images

+

+ BMP images +

Your files never leave your device.

@@ -219,7 +223,10 @@
-

+

How It Works

@@ -231,7 +238,7 @@

Upload File

-

+

Click or drag and drop your file to begin

@@ -244,7 +251,9 @@

Process

-

Click the process button to start

+

+ Click the process button to start +

@@ -255,7 +264,9 @@

Download

-

Save your processed file instantly

+

+ Save your processed file instantly +

@@ -263,7 +274,10 @@
-

+

Related PDF Tools

@@ -307,7 +321,10 @@
-

+

Frequently Asked Questions

diff --git a/src/pages/bookmark.html b/src/pages/bookmark.html index 18dddca..08d8dd5 100644 --- a/src/pages/bookmark.html +++ b/src/pages/bookmark.html @@ -115,7 +115,7 @@ class="text-2xl font-bold text-white mb-2" data-i18n="tools:editBookmarks.name" > - Bookmark Free Online - Fast & Secure + Edit Bookmarks

Add, edit, import, delete and extract PDF bookmarks. @@ -137,8 +137,10 @@ > or drag and drop

-

A single PDF file

-

+

+ A single PDF file +

+

Your files never leave your device.

@@ -644,7 +646,10 @@
-

+

How It Works

@@ -656,7 +661,7 @@

Upload File

-

+

Click or drag and drop your file to begin

@@ -669,7 +674,9 @@

Process

-

Click the process button to start

+

+ Click the process button to start +

@@ -680,7 +687,9 @@

Download

-

Save your processed file instantly

+

+ Save your processed file instantly +

@@ -688,7 +697,10 @@
-

+

Related PDF Tools

@@ -732,7 +744,10 @@
-

+

Frequently Asked Questions

diff --git a/src/pages/cbz-to-pdf.html b/src/pages/cbz-to-pdf.html index f0145ff..c3464cd 100644 --- a/src/pages/cbz-to-pdf.html +++ b/src/pages/cbz-to-pdf.html @@ -120,7 +120,7 @@ class="text-2xl font-bold text-white mb-2" data-i18n="tools:cbzToPdf.name" > - Cbz To Pdf Converter Free Online - Convert Files Fast + CBZ to PDF

Convert comic book archives (CBZ/CBR) to PDF format. Supports multiple @@ -230,7 +230,10 @@

-

+

How It Works

@@ -242,7 +245,7 @@

Upload File

-

+

Click or drag and drop your file to begin

@@ -255,7 +258,9 @@

Process

-

Click the process button to start

+

+ Click the process button to start +

@@ -266,7 +271,9 @@

Download

-

Save your processed file instantly

+

+ Save your processed file instantly +

@@ -274,7 +281,10 @@
-

+

Related PDF Tools

@@ -318,7 +328,10 @@
-

+

Frequently Asked Questions

diff --git a/src/pages/change-permissions.html b/src/pages/change-permissions.html index 51b9b49..5dd85c4 100644 --- a/src/pages/change-permissions.html +++ b/src/pages/change-permissions.html @@ -134,14 +134,13 @@ class="text-2xl font-bold text-white mb-2" data-i18n="tools:changePermissions.name" > - Change Permissions Free Online - Fast & Secure + Change Permissions

- Set or change user permissions on a PDF. Modify passwords and access - controls. + Set or change user permissions on a PDF.

- Click to select PDF + Click to select PDF or drag and drop

@@ -345,7 +346,10 @@

-

+

How It Works

@@ -357,7 +361,7 @@

Upload File

-

+

Click or drag and drop your file to begin

@@ -370,7 +374,9 @@

Process

-

Click the process button to start

+

+ Click the process button to start +

@@ -381,7 +387,9 @@

Download

-

Save your processed file instantly

+

+ Save your processed file instantly +

@@ -389,7 +397,10 @@
-

+

Related PDF Tools

@@ -433,7 +444,10 @@
-

+

Frequently Asked Questions

diff --git a/src/pages/combine-single-page.html b/src/pages/combine-single-page.html index f9bcebb..2db2d05 100644 --- a/src/pages/combine-single-page.html +++ b/src/pages/combine-single-page.html @@ -134,14 +134,13 @@ class="text-2xl font-bold text-white mb-2" data-i18n="tools:combineToSinglePage.name" > - Combine Single Page Free Online - Combine PDFs Instantly + Combine to Single Page

- Merge all pages of your PDF into one continuous page, either - horizontally or vertically. + Stitch all pages into one continuous scroll.

or drag and drop

-

A single PDF file

+

+ A single PDF file +

Your files never leave your device.

@@ -215,12 +216,7 @@ class="block mb-2 text-sm font-medium text-gray-300" >Background Color - +
- +
@@ -323,7 +314,10 @@
-

+

How It Works

@@ -335,7 +329,7 @@

Upload File

-

+

Click or drag and drop your file to begin

@@ -348,7 +342,9 @@

Process

-

Click the process button to start

+

+ Click the process button to start +

@@ -359,7 +355,9 @@

Download

-

Save your processed file instantly

+

+ Save your processed file instantly +

@@ -367,7 +365,10 @@
-

+

Related PDF Tools

@@ -411,7 +412,10 @@
-

+

Frequently Asked Questions

diff --git a/src/pages/compare-pdfs.html b/src/pages/compare-pdfs.html index 8c4d795..03346aa 100644 --- a/src/pages/compare-pdfs.html +++ b/src/pages/compare-pdfs.html @@ -72,31 +72,446 @@ @@ -134,7 +549,7 @@ >
- - Page 1 of + + Page 1 / 1 - - +
- -
+
- - +
- -
+ + + +
+ + + 100% + + + +
+ + - - - -
-
- -
-
- +
+
+
+
+ Original +
+
+ +
+ +
+
+
+
+ Modified +
+
+ +
+ +
+
+ +
@@ -362,7 +983,10 @@
-

+

How It Works

@@ -374,7 +998,7 @@

Upload File

-

+

Click or drag and drop your file to begin

@@ -387,7 +1011,9 @@

Process

-

Click the process button to start

+

+ Click the process button to start +

@@ -398,7 +1024,9 @@

Download

-

Save your processed file instantly

+

+ Save your processed file instantly +

@@ -406,7 +1034,10 @@
-

+

Related PDF Tools

@@ -450,7 +1081,10 @@
-

+

Frequently Asked Questions

diff --git a/src/pages/compress-pdf.html b/src/pages/compress-pdf.html index b6705b5..f84756b 100644 --- a/src/pages/compress-pdf.html +++ b/src/pages/compress-pdf.html @@ -119,11 +119,10 @@ class="text-2xl font-bold text-white mb-2" data-i18n="tools:compressPdf.name" > - Compress PDF Free Online - Reduce File Size Fast + Compress PDF

- Reduce file size by choosing the compression method that best suits - your document. Supports multiple PDFs. + Reduce the file size of your PDF.

- Click to select files + Click to select files or drag and drop

-

One or more PDF files

+

+ One or more PDF files +

Your files never leave your device.

@@ -399,7 +405,10 @@
-

+

How It Works

@@ -479,7 +488,10 @@
-

+

Related PDF Tools

@@ -509,7 +521,10 @@
-

+

Frequently Asked Questions

diff --git a/src/pages/crop-pdf.html b/src/pages/crop-pdf.html index e9e52fe..0562d70 100644 --- a/src/pages/crop-pdf.html +++ b/src/pages/crop-pdf.html @@ -125,11 +125,10 @@ class="text-2xl font-bold text-white mb-2" data-i18n="tools:cropPdf.name" > - Crop Pdf Free Online - Fast & Secure + Crop PDF

- Upload a PDF to visually crop one or more pages. This tool offers a - live preview and two distinct cropping modes. + Trim the margins of every page in your PDF.

@@ -148,7 +147,12 @@ > or drag and drop

-

PDF Documents

+

+ PDF Documents +

Your files never leave your device.

@@ -274,7 +278,10 @@
-

+

How It Works

@@ -286,7 +293,7 @@

Upload File

-

+

Click or drag and drop your file to begin

@@ -299,7 +306,9 @@

Process

-

Click the process button to start

+

+ Click the process button to start +

@@ -310,7 +319,9 @@

Download

-

Save your processed file instantly

+

+ Save your processed file instantly +

@@ -318,7 +329,10 @@
-

+

Related PDF Tools

@@ -362,7 +376,10 @@
-

+

Frequently Asked Questions

diff --git a/src/pages/csv-to-pdf.html b/src/pages/csv-to-pdf.html index c535352..6b9ec2a 100644 --- a/src/pages/csv-to-pdf.html +++ b/src/pages/csv-to-pdf.html @@ -116,12 +116,14 @@ -

- Csv To Pdf Converter Free Online - Convert Files Fast +

+ CSV to PDF

-

- Convert CSV (Comma-Separated Values) spreadsheet files to PDF format. - Supports multiple files. +

+ Convert CSV spreadsheet files to PDF format. Supports multiple files.

- Click to select files + Click to select files or drag and drop

-

One or more CSV files

+

+ One or more CSV files +

Your files never leave your device.

@@ -219,7 +228,10 @@
-

+

How It Works

@@ -231,7 +243,7 @@

Upload File

-

+

Click or drag and drop your file to begin

@@ -244,7 +256,9 @@

Process

-

Click the process button to start

+

+ Click the process button to start +

@@ -255,7 +269,9 @@

Download

-

Save your processed file instantly

+

+ Save your processed file instantly +

@@ -263,7 +279,10 @@
-

+

Related PDF Tools

@@ -307,7 +326,10 @@
-

+

Frequently Asked Questions

diff --git a/src/pages/decrypt-pdf.html b/src/pages/decrypt-pdf.html index cbcd38c..f6cbc70 100644 --- a/src/pages/decrypt-pdf.html +++ b/src/pages/decrypt-pdf.html @@ -120,10 +120,10 @@ class="text-2xl font-bold text-white mb-2" data-i18n="tools:decryptPdf.name" > - Decrypt PDF Free - Remove Password Online + Decrypt PDF

- Unlock password-protected PDF files by removing encryption. + Unlock PDF by removing password protection.

- Click to select PDF + Click to select PDF or drag and drop

+

+ Single or multiple PDF files supported +

Your files never leave your device.

@@ -148,9 +156,28 @@ type="file" class="absolute top-0 left-0 w-full h-full opacity-0 cursor-pointer" accept="application/pdf" + multiple />
+ + +

Process

-

Click the process button to start

+

+ Click the process button to start +

@@ -262,7 +294,9 @@

Download

-

Save your processed file instantly

+

+ Save your processed file instantly +

@@ -270,7 +304,10 @@
-

+

Related PDF Tools

@@ -314,7 +351,10 @@
-

+

Frequently Asked Questions

diff --git a/src/pages/delete-pages.html b/src/pages/delete-pages.html index c8660a9..0f612bd 100644 --- a/src/pages/delete-pages.html +++ b/src/pages/delete-pages.html @@ -111,16 +111,18 @@ class="flex items-center gap-2 text-indigo-400 hover:text-indigo-300 mb-6 font-semibold" > - Back to Tools + Back to Tools

- Delete PDF Pages Free - Remove Pages Online + Delete Pages

- Remove specific pages or ranges of pages from your PDF file. + Remove specific pages from your document.

or drag and drop

-

PDF Documents

+

+ PDF Documents +

-

+

How It Works

@@ -231,7 +241,7 @@

Upload File

-

+

Click or drag and drop your file to begin

@@ -244,7 +254,9 @@

Process

-

Click the process button to start

+

+ Click the process button to start +

@@ -255,7 +267,9 @@

Download

-

Save your processed file instantly

+

+ Save your processed file instantly +

@@ -263,7 +277,10 @@
-

+

Related PDF Tools

@@ -307,7 +324,10 @@
-

+

Frequently Asked Questions

diff --git a/src/pages/deskew-pdf.html b/src/pages/deskew-pdf.html index de0ab06..4e42d10 100644 --- a/src/pages/deskew-pdf.html +++ b/src/pages/deskew-pdf.html @@ -99,15 +99,19 @@ class="flex items-center gap-2 text-indigo-400 hover:text-indigo-300 mb-6 font-semibold" > - Back to Tools + Back to Tools -

- Deskew PDF Free Online - Straighten Tilted Scans +

+ Deskew PDF

-

- Automatically detect and correct skewed pages in scanned PDFs. Uses - advanced image processing to straighten tilted documents. +

+ Automatically straighten tilted scanned pages using OpenCV.

- Click to select files or drag - and drop + Click to select files + or drag and drop

-

One or more PDF files

-

+

+ One or more PDF files +

+

Your files never leave your device.

@@ -249,7 +260,10 @@
-

+

How It Works

@@ -292,7 +306,10 @@
-

+

Related PDF Tools

@@ -335,7 +352,10 @@
-

+

Frequently Asked Questions

diff --git a/src/pages/digital-sign-pdf.html b/src/pages/digital-sign-pdf.html index dcf4f17..c1ac4cd 100644 --- a/src/pages/digital-sign-pdf.html +++ b/src/pages/digital-sign-pdf.html @@ -162,7 +162,12 @@ > or drag and drop

-

PDF Documents

+

+ PDF Documents +

Your files never leave your device.

@@ -472,12 +477,7 @@ class="block text-sm font-medium text-gray-300 mb-2" >Text Color - +
@@ -311,7 +319,10 @@
-

+

Related PDF Tools

@@ -355,7 +366,10 @@
-

+

Frequently Asked Questions

diff --git a/src/pages/edit-attachments.html b/src/pages/edit-attachments.html index 38bb519..b5dee6a 100644 --- a/src/pages/edit-attachments.html +++ b/src/pages/edit-attachments.html @@ -134,13 +134,13 @@ class="text-2xl font-bold text-white mb-2" data-i18n="tools:editAttachments.name" > - Edit Attachments Free Online - Edit PDFs Securely + Edit Attachments

- Upload a PDF to view and remove its embedded attachments. + View or remove attachments in your PDF.

- Click to select PDF + Click to select PDF or drag and drop

@@ -228,7 +230,10 @@

-

+

How It Works

@@ -240,7 +245,7 @@

Upload File

-

+

Click or drag and drop your file to begin

@@ -253,7 +258,9 @@

Process

-

Click the process button to start

+

+ Click the process button to start +

@@ -264,7 +271,9 @@

Download

-

Save your processed file instantly

+

+ Save your processed file instantly +

@@ -272,7 +281,10 @@
-

+

Related PDF Tools

@@ -316,7 +328,10 @@
-

+

Frequently Asked Questions

diff --git a/src/pages/edit-metadata.html b/src/pages/edit-metadata.html index 27fd117..d4c3512 100644 --- a/src/pages/edit-metadata.html +++ b/src/pages/edit-metadata.html @@ -120,11 +120,10 @@ class="text-2xl font-bold text-white mb-2" data-i18n="tools:editMetadata.name" > - Edit Metadata Free Online - Edit PDFs Securely + Edit Metadata

- Edit metadata fields in your PDF document including title, author, - subject, and keywords. + Change the author, title, and other properties.

or drag and drop

-

A single PDF file

+

+ A single PDF file +

Your files never leave your device.

@@ -332,7 +333,10 @@
-

+

How It Works

@@ -344,7 +348,7 @@

Upload File

-

+

Click or drag and drop your file to begin

@@ -357,7 +361,9 @@

Process

-

Click the process button to start

+

+ Click the process button to start +

@@ -368,7 +374,9 @@

Download

-

Save your processed file instantly

+

+ Save your processed file instantly +

@@ -376,7 +384,10 @@
-

+

Related PDF Tools

@@ -420,7 +431,10 @@
-

+

Frequently Asked Questions

diff --git a/src/pages/edit-pdf.html b/src/pages/edit-pdf.html index 1f8a4af..f61308f 100644 --- a/src/pages/edit-pdf.html +++ b/src/pages/edit-pdf.html @@ -123,7 +123,7 @@ class="text-2xl font-bold text-white mb-2" data-i18n="tools:pdfEditor.name" > - Edit PDF Free Online - Modify PDFs Securely + PDF Editor

Annotate, highlight, redact, comment, add shapes/images, search, and @@ -145,7 +145,9 @@ > or drag and drop

-

PDF file

+

+ PDF file +

Your files never leave your device.

@@ -211,7 +213,10 @@
-

+

How It Works

@@ -223,7 +228,7 @@

Upload File

-

+

Click or drag and drop your file to begin

@@ -236,7 +241,9 @@

Process

-

Click the process button to start

+

+ Click the process button to start +

@@ -247,7 +254,9 @@

Download

-

Save your processed file instantly

+

+ Save your processed file instantly +

@@ -255,7 +264,10 @@
-

+

Related PDF Tools

@@ -299,7 +311,10 @@
-

+

Frequently Asked Questions

diff --git a/src/pages/email-to-pdf.html b/src/pages/email-to-pdf.html index cff9c4c..3433811 100644 --- a/src/pages/email-to-pdf.html +++ b/src/pages/email-to-pdf.html @@ -125,7 +125,7 @@ class="text-2xl font-bold text-white mb-2" data-i18n="tools:emailToPdf.name" > - Email to PDF Converter Free Online - Convert EML MSG Files + Email to PDF

Convert email files (EML, MSG) to PDF format. Supports Outlook exports @@ -289,7 +289,10 @@

-

+

How It Works

@@ -337,7 +340,10 @@
-

+

Related PDF Tools

@@ -381,7 +387,10 @@
-

+

Frequently Asked Questions

diff --git a/src/pages/encrypt-pdf.html b/src/pages/encrypt-pdf.html index 1c3940f..022aee3 100644 --- a/src/pages/encrypt-pdf.html +++ b/src/pages/encrypt-pdf.html @@ -123,11 +123,10 @@ class="text-2xl font-bold text-white mb-2" data-i18n="tools:encryptPdf.name" > - Encrypt PDF Free - Password Protect PDFs + Encrypt PDF

- Protect your PDF with 256-bit AES encryption. Add password protection - and set usage restrictions. + Lock your PDF by adding a password.

- Click to select PDF + Click to select PDF or drag and drop

@@ -260,7 +261,10 @@

-

+

How It Works

@@ -272,7 +276,7 @@

Upload File

-

+

Click or drag and drop your file to begin

@@ -285,7 +289,9 @@

Process

-

Click the process button to start

+

+ Click the process button to start +

@@ -296,7 +302,9 @@

Download

-

Save your processed file instantly

+

+ Save your processed file instantly +

@@ -304,7 +312,10 @@
-

+

Related PDF Tools

@@ -348,7 +359,10 @@
-

+

Frequently Asked Questions

diff --git a/src/pages/epub-to-pdf.html b/src/pages/epub-to-pdf.html index 8fb2693..d5ebe4d 100644 --- a/src/pages/epub-to-pdf.html +++ b/src/pages/epub-to-pdf.html @@ -120,7 +120,7 @@ class="text-2xl font-bold text-white mb-2" data-i18n="tools:epubToPdf.name" > - Epub To Pdf Converter Free Online - Convert Files Fast + EPUB to PDF

Convert EPUB e-books to PDF format. Supports multiple files. @@ -231,7 +231,10 @@

-

+

How It Works

@@ -243,7 +246,7 @@

Upload File

-

+

Click or drag and drop your file to begin

@@ -256,7 +259,9 @@

Process

-

Click the process button to start

+

+ Click the process button to start +

@@ -267,7 +272,9 @@

Download

-

Save your processed file instantly

+

+ Save your processed file instantly +

@@ -275,7 +282,10 @@
-

+

Related PDF Tools

@@ -319,7 +329,10 @@
-

+

Frequently Asked Questions

diff --git a/src/pages/excel-to-pdf.html b/src/pages/excel-to-pdf.html index 15b0b22..6376392 100644 --- a/src/pages/excel-to-pdf.html +++ b/src/pages/excel-to-pdf.html @@ -123,7 +123,7 @@ class="text-2xl font-bold text-white mb-2" data-i18n="tools:excelToPdf.name" > - Excel to PDF Converter Free - Convert XLSX Online + Excel to PDF

Convert Excel spreadsheets (XLSX, XLS, ODS, CSV) to PDF format. @@ -235,7 +235,10 @@

-

+

How It Works

@@ -247,7 +250,7 @@

Upload File

-

+

Click or drag and drop your file to begin

@@ -260,7 +263,9 @@

Process

-

Click the process button to start

+

+ Click the process button to start +

@@ -271,7 +276,9 @@

Download

-

Save your processed file instantly

+

+ Save your processed file instantly +

@@ -279,7 +286,10 @@
-

+

Related PDF Tools

@@ -323,7 +333,10 @@
-

+

Frequently Asked Questions

diff --git a/src/pages/extract-attachments.html b/src/pages/extract-attachments.html index 4df2ae9..ecad1fd 100644 --- a/src/pages/extract-attachments.html +++ b/src/pages/extract-attachments.html @@ -134,14 +134,13 @@ class="text-2xl font-bold text-white mb-2" data-i18n="tools:extractAttachments.name" > - Extract Attachments Free Online - Extract PDF Content + Extract Attachments

- Upload PDF files to extract all embedded attachments. Files will be - downloaded as a ZIP archive. + Extract all embedded files from PDF(s) as a ZIP.

- Click to select PDFs + Click to select PDFs or drag and drop

-

Multiple PDF files supported

+

+ Multiple PDF files supported +

Your files never leave your device.

@@ -233,7 +239,10 @@
-

+

How It Works

@@ -245,7 +254,7 @@

Upload File

-

+

Click or drag and drop your file to begin

@@ -258,7 +267,9 @@

Process

-

Click the process button to start

+

+ Click the process button to start +

@@ -269,7 +280,9 @@

Download

-

Save your processed file instantly

+

+ Save your processed file instantly +

@@ -277,7 +290,10 @@
-

+

Related PDF Tools

@@ -321,7 +337,10 @@
-

+

Frequently Asked Questions

diff --git a/src/pages/extract-images.html b/src/pages/extract-images.html index 96eac7b..e0a9a9f 100644 --- a/src/pages/extract-images.html +++ b/src/pages/extract-images.html @@ -117,15 +117,19 @@ class="flex items-center gap-2 text-indigo-400 hover:text-indigo-300 mb-6 font-semibold" > - Back to Tools + Back to Tools -

- Extract Images Free Online - Extract PDF Content +

+ Extract Images

-

- Extract all embedded images from PDF files. Download individually or - as a ZIP archive. +

+ Extract all embedded images from your PDF files.

- Click to select files - or drag and drop + Click to select files + or drag and drop

-

One or more PDF files

-

+

+ One or more PDF files +

+

Your files never leave your device.

@@ -232,7 +243,10 @@
-

+

How It Works

@@ -244,7 +258,7 @@

Upload File

-

+

Click or drag and drop your file to begin

@@ -257,7 +271,9 @@

Process

-

Click the process button to start

+

+ Click the process button to start +

@@ -268,7 +284,9 @@

Download

-

Save your processed file instantly

+

+ Save your processed file instantly +

@@ -276,7 +294,10 @@
-

+

Related PDF Tools

@@ -320,7 +341,10 @@
-

+

Frequently Asked Questions

diff --git a/src/pages/extract-pages.html b/src/pages/extract-pages.html index 2855e4b..823458d 100644 --- a/src/pages/extract-pages.html +++ b/src/pages/extract-pages.html @@ -120,11 +120,10 @@ class="text-2xl font-bold text-white mb-2" data-i18n="tools:extractPages.name" > - Extract PDF Pages Free - Copy Pages Online + Extract Pages

- Extract specific pages from a PDF into separate files. Your files will - download in a ZIP archive. + Save a selection of pages as new files.

@@ -143,7 +142,12 @@ > or drag and drop

-

PDF Documents

+

+ PDF Documents +

Your files never leave your device.

@@ -225,7 +229,10 @@
-

+

How It Works

@@ -237,7 +244,7 @@

Upload File

-

+

Click or drag and drop your file to begin

@@ -250,7 +257,9 @@

Process

-

Click the process button to start

+

+ Click the process button to start +

@@ -261,7 +270,9 @@

Download

-

Save your processed file instantly

+

+ Save your processed file instantly +

@@ -269,7 +280,10 @@
-

+

Related PDF Tools

@@ -313,7 +327,10 @@
-

+

Frequently Asked Questions

diff --git a/src/pages/extract-tables.html b/src/pages/extract-tables.html index 55a8d13..63df1a0 100644 --- a/src/pages/extract-tables.html +++ b/src/pages/extract-tables.html @@ -126,7 +126,7 @@ class="text-2xl font-bold text-white mb-2" data-i18n="tools:extractTables.name" > - Extract Tables Free Online - Extract PDF Content + Extract PDF Tables

Extract tables from PDF files and export as CSV, JSON, or Markdown. @@ -147,7 +147,9 @@ > or drag and drop

-

PDF file

+

+ PDF file +

Your files never leave your device.

@@ -245,7 +247,10 @@
-

+

How It Works

@@ -257,7 +262,7 @@

Upload File

-

+

Click or drag and drop your file to begin

@@ -270,7 +275,9 @@

Process

-

Click the process button to start

+

+ Click the process button to start +

@@ -281,7 +288,9 @@

Download

-

Save your processed file instantly

+

+ Save your processed file instantly +

@@ -289,7 +298,10 @@
-

+

Related PDF Tools

@@ -333,7 +345,10 @@
-

+

Frequently Asked Questions

diff --git a/src/pages/fb2-to-pdf.html b/src/pages/fb2-to-pdf.html index 7dbe46d..74748f7 100644 --- a/src/pages/fb2-to-pdf.html +++ b/src/pages/fb2-to-pdf.html @@ -120,7 +120,7 @@ class="text-2xl font-bold text-white mb-2" data-i18n="tools:fb2ToPdf.name" > - Fb2 To Pdf Converter Free Online - Convert Files Fast + FB2 to PDF

Convert FictionBook (FB2) e-books to PDF format. Supports multiple @@ -230,7 +230,10 @@

-

+

How It Works

@@ -242,7 +245,7 @@

Upload File

-

+

Click or drag and drop your file to begin

@@ -255,7 +258,9 @@

Process

-

Click the process button to start

+

+ Click the process button to start +

@@ -266,7 +271,9 @@

Download

-

Save your processed file instantly

+

+ Save your processed file instantly +

@@ -274,7 +281,10 @@
-

+

Related PDF Tools

@@ -318,7 +328,10 @@
-

+

Frequently Asked Questions

diff --git a/src/pages/fix-page-size.html b/src/pages/fix-page-size.html index fc03e64..56fa41b 100644 --- a/src/pages/fix-page-size.html +++ b/src/pages/fix-page-size.html @@ -120,11 +120,10 @@ class="text-2xl font-bold text-white mb-2" data-i18n="tools:fixPageSize.name" > - Fix Page Size Free Online - Fast & Secure + Fix Page Size

- Standardize all pages in your PDF to a uniform size. Choose from - standard sizes or set custom dimensions. + Standardize all pages to a uniform size.

- Click to select PDF + Click to select PDF or drag and drop

@@ -294,12 +295,7 @@ >Background Color

- + Color for margins/padding @@ -361,7 +357,10 @@
-

+

How It Works

@@ -373,7 +372,7 @@

Upload File

-

+

Click or drag and drop your file to begin

@@ -386,7 +385,9 @@

Process

-

Click the process button to start

+

+ Click the process button to start +

@@ -397,7 +398,9 @@

Download

-

Save your processed file instantly

+

+ Save your processed file instantly +

@@ -405,7 +408,10 @@
-

+

Related PDF Tools

@@ -449,7 +455,10 @@
-

+

Frequently Asked Questions

diff --git a/src/pages/flatten-pdf.html b/src/pages/flatten-pdf.html index 01c34fd..5bb681e 100644 --- a/src/pages/flatten-pdf.html +++ b/src/pages/flatten-pdf.html @@ -120,11 +120,10 @@ class="text-2xl font-bold text-white mb-2" data-i18n="tools:flattenPdf.name" > - Flatten Pdf Free Online - Fast & Secure + Flatten PDF

- Make form fields and annotations non-editable by flattening them into - the PDF. + Make form fields and annotations non-editable.

- Click to select PDFs + Click to select PDFs or drag and drop

-

+

Single or multiple PDF files supported

@@ -247,7 +251,10 @@

-

+

How It Works

@@ -259,7 +266,7 @@

Upload File

-

+

Click or drag and drop your file to begin

@@ -272,7 +279,9 @@

Process

-

Click the process button to start

+

+ Click the process button to start +

@@ -283,7 +292,9 @@

Download

-

Save your processed file instantly

+

+ Save your processed file instantly +

@@ -291,7 +302,10 @@
-

+

Related PDF Tools

@@ -335,7 +349,10 @@
-

+

Frequently Asked Questions

diff --git a/src/pages/font-to-outline.html b/src/pages/font-to-outline.html index 31f1932..04a711b 100644 --- a/src/pages/font-to-outline.html +++ b/src/pages/font-to-outline.html @@ -110,16 +110,20 @@ class="flex items-center gap-2 text-indigo-400 hover:text-indigo-300 mb-6 font-semibold" > - Back to Tools + Back to Tools -

- Font to Outline Free Online - Convert Fonts to Paths +

+ Font to Outline

-

- Convert all fonts in your PDF to vector outlines/paths. Ensures - consistent rendering across all devices regardless of font - availability. +

+ Convert all fonts to vector outlines for consistent rendering across + all devices.

- Click to select files or drag - and drop + Click to select files + or drag and drop

-

One or more PDF files

-

+

+ One or more PDF files +

+

Your files never leave your device.

@@ -224,7 +235,10 @@
-

+

How It Works

@@ -269,7 +283,10 @@
-

+

Related PDF Tools

@@ -312,7 +329,10 @@
-

+

Frequently Asked Questions

diff --git a/src/pages/form-creator.html b/src/pages/form-creator.html index 5d79be9..c417ade 100644 --- a/src/pages/form-creator.html +++ b/src/pages/form-creator.html @@ -111,18 +111,19 @@ class="flex items-center gap-2 text-indigo-400 hover:text-indigo-300 mb-6 font-semibold" > - Back to Tools + Back to Tools

- Form Creator Free Online - Fast & Secure + Create PDF Form

- Upload an existing PDF or create a blank PDF to start adding form - fields. + Create fillable PDF forms with drag-and-drop text fields.

- Click to select a PDF + Click to select a PDF or drag and drop

-

Single PDF file

+

+ Single PDF file +

Your files never leave your device.

@@ -241,7 +249,9 @@ class="flex items-center gap-2 text-indigo-400 hover:text-indigo-300 mb-6 font-semibold" > - Back to Tools + Back to Tools

- Drag and drop fields onto the canvas to create a fillable PDF form. - Customize field properties and download your form. + Create fillable PDF forms with drag-and-drop text fields.

@@ -493,6 +502,16 @@ Image

+ +
+ + Barcode +
@@ -523,7 +542,10 @@
-

+

How It Works

@@ -535,7 +557,7 @@

Upload File

-

+

Click or drag and drop your file to begin

@@ -548,7 +570,9 @@

Process

-

Click the process button to start

+

+ Click the process button to start +

@@ -559,7 +583,9 @@

Download

-

Save your processed file instantly

+

+ Save your processed file instantly +

@@ -567,7 +593,10 @@
-

+

Related PDF Tools

@@ -611,7 +640,10 @@
-

+

Frequently Asked Questions

diff --git a/src/pages/form-filler.html b/src/pages/form-filler.html index b5f9f0a..c9d5c15 100644 --- a/src/pages/form-filler.html +++ b/src/pages/form-filler.html @@ -120,7 +120,7 @@ class="text-2xl font-bold text-white mb-2" data-i18n="tools:pdfFormFiller.name" > - Form Filler Free Online - Fast & Secure + PDF Form Filler

Upload a PDF with form fields. Fill them directly in the viewer below, @@ -156,7 +156,12 @@ > or drag and drop

-

PDF file with form fields

+

+ PDF file with form fields +

Your files never leave your device.

@@ -231,7 +236,10 @@
-

+

How It Works

@@ -243,7 +251,7 @@

Upload File

-

+

Click or drag and drop your file to begin

@@ -256,7 +264,9 @@

Process

-

Click the process button to start

+

+ Click the process button to start +

@@ -267,7 +277,9 @@

Download

-

Save your processed file instantly

+

+ Save your processed file instantly +

@@ -275,7 +287,10 @@
-

+

Related PDF Tools

@@ -319,7 +334,10 @@
-

+

Frequently Asked Questions

diff --git a/src/pages/header-footer.html b/src/pages/header-footer.html index 9482415..01b1149 100644 --- a/src/pages/header-footer.html +++ b/src/pages/header-footer.html @@ -120,11 +120,10 @@ class="text-2xl font-bold text-white mb-2" data-i18n="tools:headerFooter.name" > - Header Footer Free Online - Fast & Secure + Header & Footer

- Add customizable headers and footers to your PDF documents with - dynamic page numbers. + Add text to the top and bottom of pages.

or drag and drop

-

PDF file

+

+ PDF file +

Your files never leave your device.

@@ -200,12 +201,7 @@ class="block mb-2 text-sm font-medium text-gray-300" >Font Color - +
@@ -347,7 +343,10 @@
-

+

How It Works

@@ -359,7 +358,7 @@

Upload File

-

+

Click or drag and drop your file to begin

@@ -372,7 +371,9 @@

Process

-

Click the process button to start

+

+ Click the process button to start +

@@ -383,7 +384,9 @@

Download

-

Save your processed file instantly

+

+ Save your processed file instantly +

@@ -391,7 +394,10 @@
-

+

Related PDF Tools

@@ -435,7 +441,10 @@
-

+

Frequently Asked Questions

diff --git a/src/pages/heic-to-pdf.html b/src/pages/heic-to-pdf.html index eed184c..b3281aa 100644 --- a/src/pages/heic-to-pdf.html +++ b/src/pages/heic-to-pdf.html @@ -120,11 +120,10 @@ class="text-2xl font-bold text-white mb-2" data-i18n="tools:heicToPdf.name" > - Heic To Pdf Converter Free Online - Convert Files Fast + HEIC to PDF

- Convert one or more HEIC (High Efficiency) images from your iPhone or - camera into a single PDF file. + Create a PDF from one or more HEIC images.

- Click to select files + Click to select files or drag and drop

-

HEIC/HEIF images

+

+ HEIC/HEIF images +

Your files never leave your device.

@@ -220,7 +226,10 @@
-

+

How It Works

@@ -232,7 +241,7 @@

Upload File

-

+

Click or drag and drop your file to begin

@@ -245,7 +254,9 @@

Process

-

Click the process button to start

+

+ Click the process button to start +

@@ -256,7 +267,9 @@

Download

-

Save your processed file instantly

+

+ Save your processed file instantly +

@@ -264,7 +277,10 @@
-

+

Related PDF Tools

@@ -308,7 +324,10 @@
-

+

Frequently Asked Questions

diff --git a/src/pages/image-to-pdf.html b/src/pages/image-to-pdf.html index 8518740..fdaed4f 100644 --- a/src/pages/image-to-pdf.html +++ b/src/pages/image-to-pdf.html @@ -121,10 +121,11 @@ class="text-2xl font-bold text-white mb-2" data-i18n="tools:imageToPdf.name" > - Image To Pdf Converter Free Online - Convert Files Fast + Images to PDF

- Convert one or more images into a single PDF file. + Convert JPG, PNG, BMP, GIF, TIFF, PNM, PGM, PBM, PPM, PAM, JXR, JPX, + JP2, PSD, SVG, HEIC, WebP to PDF.

-

+

How It Works

@@ -264,7 +268,7 @@

Upload File

-

+

Click or drag and drop your file to begin

@@ -277,7 +281,9 @@

Process

-

Click the process button to start

+

+ Click the process button to start +

@@ -288,7 +294,9 @@

Download

-

Save your processed file instantly

+

+ Save your processed file instantly +

@@ -296,7 +304,10 @@
-

+

Related PDF Tools

@@ -340,7 +351,10 @@
-

+

Frequently Asked Questions

diff --git a/src/pages/invert-colors.html b/src/pages/invert-colors.html index 2ddc1bd..0904944 100644 --- a/src/pages/invert-colors.html +++ b/src/pages/invert-colors.html @@ -120,11 +120,10 @@ class="text-2xl font-bold text-white mb-2" data-i18n="tools:invertColors.name" > - Invert Colors Free Online - Fast & Secure + Invert Colors

- Invert all colors in your PDF document, creating a negative image - effect. + Create a "dark mode" version of your PDF.

or drag and drop

-

PDF file

+

+ PDF file +

Your files never leave your device.

@@ -214,7 +215,10 @@
-

+

How It Works

@@ -226,7 +230,7 @@

Upload File

-

+

Click or drag and drop your file to begin

@@ -239,7 +243,9 @@

Process

-

Click the process button to start

+

+ Click the process button to start +

@@ -250,7 +256,9 @@

Download

-

Save your processed file instantly

+

+ Save your processed file instantly +

@@ -258,7 +266,10 @@
-

+

Related PDF Tools

@@ -302,7 +313,10 @@
-

+

Frequently Asked Questions

diff --git a/src/pages/jpg-to-pdf.html b/src/pages/jpg-to-pdf.html index eebc4dc..2c724cf 100644 --- a/src/pages/jpg-to-pdf.html +++ b/src/pages/jpg-to-pdf.html @@ -123,7 +123,7 @@ class="text-2xl font-bold text-white mb-2" data-i18n="tools:jpgToPdf.name" > - JPG to PDF Converter Free - Convert Images Online + JPG to PDF

Create a PDF from JPG, JPEG, and JPEG2000 (JP2/JPX) images. @@ -145,7 +145,9 @@ > or drag and drop

-

JPG, JPEG, JP2, JPX Images

+

+ JPG, JPEG, JP2, JPX Images +

Your files never leave your device.

@@ -252,7 +254,10 @@
-

+

How It Works

@@ -264,7 +269,7 @@

Upload File

-

+

Click or drag and drop your file to begin

@@ -277,7 +282,9 @@

Process

-

Click the process button to start

+

+ Click the process button to start +

@@ -288,7 +295,9 @@

Download

-

Save your processed file instantly

+

+ Save your processed file instantly +

@@ -296,7 +305,10 @@
-

+

Related PDF Tools

@@ -340,7 +352,10 @@
-

+

Frequently Asked Questions

diff --git a/src/pages/json-to-pdf.html b/src/pages/json-to-pdf.html index b4c3718..d930325 100644 --- a/src/pages/json-to-pdf.html +++ b/src/pages/json-to-pdf.html @@ -112,11 +112,10 @@ class="text-2xl font-bold text-white mb-2" data-i18n="tools:jsonToPdf.name" > - Json To Pdf Converter Free Online - Convert Files Fast + JSON to PDF

- Upload multiple JSON files to convert them all to PDF format. Files - will be downloaded as a ZIP archive. + Convert JSON files to PDF format.

- Click to select files + Click to select files or drag and drop

Multiple JSON files

@@ -176,7 +177,10 @@
-

+

How It Works

@@ -188,7 +192,7 @@

Upload File

-

+

Click or drag and drop your file to begin

@@ -201,7 +205,9 @@

Process

-

Click the process button to start

+

+ Click the process button to start +

@@ -212,7 +218,9 @@

Download

-

Save your processed file instantly

+

+ Save your processed file instantly +

@@ -220,7 +228,10 @@
-

+

Related PDF Tools

@@ -264,7 +275,10 @@
-

+

Frequently Asked Questions

diff --git a/src/pages/linearize-pdf.html b/src/pages/linearize-pdf.html index cb68aae..870516a 100644 --- a/src/pages/linearize-pdf.html +++ b/src/pages/linearize-pdf.html @@ -120,11 +120,10 @@ class="text-2xl font-bold text-white mb-2" data-i18n="tools:linearizePdf.name" > - Linearize Pdf Free Online - Fast & Secure + Linearize PDF

- Optimize PDF files for fast web viewing. Linearized PDFs load - progressively in browsers. + Optimize PDF for fast web viewing.

- Click to select PDFs + Click to select PDFs or drag and drop

-

+

Single or multiple PDF files supported

@@ -243,7 +247,10 @@

-

+

How It Works

@@ -255,7 +262,7 @@

Upload File

-

+

Click or drag and drop your file to begin

@@ -268,7 +275,9 @@

Process

-

Click the process button to start

+

+ Click the process button to start +

@@ -279,7 +288,9 @@

Download

-

Save your processed file instantly

+

+ Save your processed file instantly +

@@ -287,7 +298,10 @@
-

+

Related PDF Tools

@@ -331,7 +345,10 @@
-

+

Frequently Asked Questions

diff --git a/src/pages/markdown-to-pdf.html b/src/pages/markdown-to-pdf.html index 2c8fdf9..d4dd08f 100644 --- a/src/pages/markdown-to-pdf.html +++ b/src/pages/markdown-to-pdf.html @@ -126,12 +126,18 @@ > -

- Markdown To Pdf Converter Free Online - Convert Files Fast +

+ Markdown to PDF

-

- Write markdown with live preview. Supports syntax highlighting, - tables, emoji, and more. Print to save as PDF. +

+ Write or paste Markdown and export it as a beautifully formatted + PDF.

@@ -142,7 +148,10 @@
-

+

How It Works

@@ -154,7 +163,7 @@

Upload File

-

+

Click or drag and drop your file to begin

@@ -167,7 +176,9 @@

Process

-

Click the process button to start

+

+ Click the process button to start +

@@ -178,7 +189,9 @@

Download

-

Save your processed file instantly

+

+ Save your processed file instantly +

@@ -186,7 +199,10 @@
-

+

Related PDF Tools

@@ -230,7 +246,10 @@
-

+

Frequently Asked Questions

diff --git a/src/pages/merge-pdf.html b/src/pages/merge-pdf.html index 6581596..5719001 100644 --- a/src/pages/merge-pdf.html +++ b/src/pages/merge-pdf.html @@ -119,7 +119,7 @@ class="text-2xl font-bold text-white mb-2" data-i18n="tools:mergePdf.name" > - Merge PDF Files Free - Combine PDFs Instantly + Merge PDF

Combine whole files, or select specific pages to merge into a new @@ -294,7 +294,10 @@

-

+

How It Works

@@ -355,7 +358,10 @@
-

+

Related PDF Tools

@@ -399,7 +405,10 @@
-

+

Frequently Asked Questions

diff --git a/src/pages/mobi-to-pdf.html b/src/pages/mobi-to-pdf.html index 82861c9..5e006b9 100644 --- a/src/pages/mobi-to-pdf.html +++ b/src/pages/mobi-to-pdf.html @@ -120,7 +120,7 @@ class="text-2xl font-bold text-white mb-2" data-i18n="tools:mobiToPdf.name" > - Mobi To Pdf Converter Free Online - Convert Files Fast + MOBI to PDF

Convert MOBI e-books to PDF format. Supports multiple files. @@ -229,7 +229,10 @@

-

+

How It Works

@@ -241,7 +244,7 @@

Upload File

-

+

Click or drag and drop your file to begin

@@ -254,7 +257,9 @@

Process

-

Click the process button to start

+

+ Click the process button to start +

@@ -265,7 +270,9 @@

Download

-

Save your processed file instantly

+

+ Save your processed file instantly +

@@ -273,7 +280,10 @@
-

+

Related PDF Tools

@@ -317,7 +327,10 @@
-

+

Frequently Asked Questions

diff --git a/src/pages/n-up-pdf.html b/src/pages/n-up-pdf.html index c7faafb..d6381ed 100644 --- a/src/pages/n-up-pdf.html +++ b/src/pages/n-up-pdf.html @@ -120,11 +120,10 @@ class="text-2xl font-bold text-white mb-2" data-i18n="tools:nUpPdf.name" > - N Up Pdf Free Online - Fast & Secure + N-Up PDF

- Arrange multiple pages onto a single sheet. Perfect for printing - handouts or saving paper. + Arrange multiple pages onto a single sheet.

or drag and drop

-

A single PDF file

+

+ A single PDF file +

Your files never leave your device.

@@ -251,12 +252,7 @@ class="block mb-2 text-sm font-medium text-gray-300" >Border Color - +
@@ -315,7 +311,10 @@
-

+

How It Works

@@ -327,7 +326,7 @@

Upload File

-

+

Click or drag and drop your file to begin

@@ -340,7 +339,9 @@

Process

-

Click the process button to start

+

+ Click the process button to start +

@@ -351,7 +352,9 @@

Download

-

Save your processed file instantly

+

+ Save your processed file instantly +

@@ -359,7 +362,10 @@
-

+

Related PDF Tools

@@ -403,7 +409,10 @@
-

+

Frequently Asked Questions

diff --git a/src/pages/ocr-pdf.html b/src/pages/ocr-pdf.html index 59c6399..0baa3a1 100644 --- a/src/pages/ocr-pdf.html +++ b/src/pages/ocr-pdf.html @@ -123,11 +123,10 @@ class="text-2xl font-bold text-white mb-2" data-i18n="tools:ocrPdf.name" > - OCR PDF Free Online - Extract Text from Scans + OCR PDF

- Convert scanned PDFs into searchable documents. Select one or more - languages present in your file for the best results. + Make a PDF searchable and copyable.

@@ -170,7 +169,9 @@ class="w-10 h-10 mb-3 text-gray-400" >

- Click to select PDF + Click to select PDF or drag and drop

@@ -213,6 +214,10 @@ >None

+
@@ -424,7 +429,10 @@
-

+

How It Works

@@ -436,7 +444,7 @@

Upload File

-

+

Click or drag and drop your file to begin

@@ -449,7 +457,9 @@

Process

-

Click the process button to start

+

+ Click the process button to start +

@@ -460,7 +470,9 @@

Download

-

Save your processed file instantly

+

+ Save your processed file instantly +

@@ -468,7 +480,10 @@
-

+

Related PDF Tools

@@ -512,7 +527,10 @@
-

+

Frequently Asked Questions

diff --git a/src/pages/odg-to-pdf.html b/src/pages/odg-to-pdf.html index efb033c..dae4055 100644 --- a/src/pages/odg-to-pdf.html +++ b/src/pages/odg-to-pdf.html @@ -120,7 +120,7 @@ class="text-2xl font-bold text-white mb-2" data-i18n="tools:odgToPdf.name" > - Odg To Pdf Converter Free Online - Convert Files Fast + ODG to PDF

Convert OpenDocument Graphics (ODG) files to PDF format. Supports @@ -137,7 +137,9 @@ class="w-10 h-10 mb-3 text-gray-400" >

- Click to select files + Click to select files or drag and drop

-

+

How It Works

@@ -246,7 +251,7 @@

Upload File

-

+

Click or drag and drop your file to begin

@@ -259,7 +264,9 @@

Process

-

Click the process button to start

+

+ Click the process button to start +

@@ -270,7 +277,9 @@

Download

-

Save your processed file instantly

+

+ Save your processed file instantly +

@@ -278,7 +287,10 @@
-

+

Related PDF Tools

@@ -322,7 +334,10 @@
-

+

Frequently Asked Questions

diff --git a/src/pages/odp-to-pdf.html b/src/pages/odp-to-pdf.html index 2ccab1f..100c7a4 100644 --- a/src/pages/odp-to-pdf.html +++ b/src/pages/odp-to-pdf.html @@ -122,7 +122,7 @@ class="text-2xl font-bold text-white mb-2" data-i18n="tools:odpToPdf.name" > - Odp To Pdf Converter Free Online - Convert Files Fast + ODP to PDF

Convert OpenDocument Presentation (ODP) files to PDF format. Supports @@ -139,7 +139,9 @@ class="w-10 h-10 mb-3 text-gray-400" >

- Click to select files + Click to select files or drag and drop

-

+

How It Works

@@ -248,7 +253,7 @@

Upload File

-

+

Click or drag and drop your file to begin

@@ -261,7 +266,9 @@

Process

-

Click the process button to start

+

+ Click the process button to start +

@@ -272,7 +279,9 @@

Download

-

Save your processed file instantly

+

+ Save your processed file instantly +

@@ -280,7 +289,10 @@
-

+

Related PDF Tools

@@ -324,7 +336,10 @@
-

+

Frequently Asked Questions

diff --git a/src/pages/ods-to-pdf.html b/src/pages/ods-to-pdf.html index 4b928fd..8c1f464 100644 --- a/src/pages/ods-to-pdf.html +++ b/src/pages/ods-to-pdf.html @@ -122,7 +122,7 @@ class="text-2xl font-bold text-white mb-2" data-i18n="tools:odsToPdf.name" > - Ods To Pdf Converter Free Online - Convert Files Fast + ODS to PDF

Convert OpenDocument Spreadsheet (ODS) files to PDF format. Supports @@ -139,7 +139,9 @@ class="w-10 h-10 mb-3 text-gray-400" >

- Click to select files + Click to select files or drag and drop

-

+

How It Works

@@ -248,7 +253,7 @@

Upload File

-

+

Click or drag and drop your file to begin

@@ -261,7 +266,9 @@

Process

-

Click the process button to start

+

+ Click the process button to start +

@@ -272,7 +279,9 @@

Download

-

Save your processed file instantly

+

+ Save your processed file instantly +

@@ -280,7 +289,10 @@
-

+

Related PDF Tools

@@ -324,7 +336,10 @@
-

+

Frequently Asked Questions

diff --git a/src/pages/odt-to-pdf.html b/src/pages/odt-to-pdf.html index ee580c2..f6e3a4a 100644 --- a/src/pages/odt-to-pdf.html +++ b/src/pages/odt-to-pdf.html @@ -116,12 +116,15 @@ -

- Odt To Pdf Converter Free Online - Convert Files Fast +

+ ODT to PDF

-

- Convert OpenDocument Text (.odt) files to PDF format. Supports - multiple files. +

+ Convert OpenDocument Text files to PDF format. Supports multiple + files.

- Click to select files + Click to select files or drag and drop

-

One or more ODT files

+

+ One or more ODT files +

Your files never leave your device.

@@ -219,7 +229,10 @@
-

+

How It Works

@@ -231,7 +244,7 @@

Upload File

-

+

Click or drag and drop your file to begin

@@ -244,7 +257,9 @@

Process

-

Click the process button to start

+

+ Click the process button to start +

@@ -255,7 +270,9 @@

Download

-

Save your processed file instantly

+

+ Save your processed file instantly +

@@ -263,7 +280,10 @@
-

+

Related PDF Tools

@@ -307,7 +327,10 @@
-

+

Frequently Asked Questions

diff --git a/src/pages/organize-pdf.html b/src/pages/organize-pdf.html index d89af8e..ee9abf2 100644 --- a/src/pages/organize-pdf.html +++ b/src/pages/organize-pdf.html @@ -111,29 +111,21 @@ class="flex items-center gap-2 text-indigo-400 hover:text-indigo-300 mb-6 font-semibold" > - Back to Tools + Back to Tools

- Organize PDF Pages Free - Reorder PDFs Online + Duplicate & Organize

- Drag pages to reorder them. Use the - - icon to duplicate a page or the - - icon to delete it. + Duplicate, reorder, and delete pages.

@@ -152,7 +144,12 @@ > or drag and drop

-

PDF Documents

+

+ PDF Documents +

Your files never leave your device.

@@ -174,6 +171,32 @@ class="hidden grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 gap-4 my-6" >
+ + + @@ -226,7 +249,10 @@
-

+

How It Works

@@ -238,7 +264,7 @@

Upload File

-

+

Click or drag and drop your file to begin

@@ -251,7 +277,9 @@

Process

-

Click the process button to start

+

+ Click the process button to start +

@@ -262,7 +290,9 @@

Download

-

Save your processed file instantly

+

+ Save your processed file instantly +

@@ -270,7 +300,10 @@
-

+

Related PDF Tools

@@ -314,7 +347,10 @@
-

+

Frequently Asked Questions

diff --git a/src/pages/page-dimensions.html b/src/pages/page-dimensions.html index 9021479..65dc3f7 100644 --- a/src/pages/page-dimensions.html +++ b/src/pages/page-dimensions.html @@ -132,11 +132,10 @@ class="text-2xl font-bold text-white mb-2" data-i18n="tools:pageDimensions.name" > - Page Dimensions Free Online - Fast & Secure + Page Dimensions

- Analyze PDF page dimensions and sizes. View detailed information about - each page. + Analyze page size, orientation, and units.

- Click to select PDF + Click to select PDF or drag and drop

@@ -248,7 +249,10 @@

-

+

How It Works

@@ -260,7 +264,7 @@

Upload File

-

+

Click or drag and drop your file to begin

@@ -273,7 +277,9 @@

Process

-

Click the process button to start

+

+ Click the process button to start +

@@ -284,7 +290,9 @@

Download

-

Save your processed file instantly

+

+ Save your processed file instantly +

@@ -292,7 +300,10 @@
-

+

Related PDF Tools

@@ -336,7 +347,10 @@
-

+

Frequently Asked Questions

diff --git a/src/pages/page-numbers.html b/src/pages/page-numbers.html index 9dcad4f..623ce8c 100644 --- a/src/pages/page-numbers.html +++ b/src/pages/page-numbers.html @@ -120,11 +120,10 @@ class="text-2xl font-bold text-white mb-2" data-i18n="tools:pageNumbers.name" > - Page Numbers Free Online - Fast & Secure + Page Numbers

- Add page numbers to your PDF documents with customizable position, - format, and styling. + Insert page numbers into your document.

or drag and drop

-

PDF file

+

+ PDF file +

Your files never leave your device.

@@ -217,12 +218,7 @@ class="block mb-2 text-sm font-medium text-gray-300" >Color - +
@@ -279,7 +275,10 @@
-

+

How It Works

@@ -291,7 +290,7 @@

Upload File

-

+

Click or drag and drop your file to begin

@@ -304,7 +303,9 @@

Process

-

Click the process button to start

+

+ Click the process button to start +

@@ -315,7 +316,9 @@

Download

-

Save your processed file instantly

+

+ Save your processed file instantly +

@@ -323,7 +326,10 @@
-

+

Related PDF Tools

@@ -367,7 +373,10 @@
-

+

Frequently Asked Questions

diff --git a/src/pages/pages-to-pdf.html b/src/pages/pages-to-pdf.html index 52bcaf9..bcb8524 100644 --- a/src/pages/pages-to-pdf.html +++ b/src/pages/pages-to-pdf.html @@ -120,7 +120,7 @@ class="text-2xl font-bold text-white mb-2" data-i18n="tools:pagesToPdf.name" > - Pages To Pdf Converter Free Online - Convert Files Fast + Pages to PDF

Convert Apple Pages documents to PDF format. Supports multiple files. @@ -136,7 +136,9 @@ class="w-10 h-10 mb-3 text-gray-400" >

- Click to select files + Click to select files or drag and drop

-

+

How It Works

@@ -247,7 +252,7 @@

Upload File

-

+

Click or drag and drop your file to begin

@@ -260,7 +265,9 @@

Process

-

Click the process button to start

+

+ Click the process button to start +

@@ -271,7 +278,9 @@

Download

-

Save your processed file instantly

+

+ Save your processed file instantly +

@@ -279,7 +288,10 @@
-

+

Related PDF Tools

@@ -323,7 +335,10 @@
-

+

Frequently Asked Questions

diff --git a/src/pages/pdf-booklet.html b/src/pages/pdf-booklet.html index 037d186..1fbd329 100644 --- a/src/pages/pdf-booklet.html +++ b/src/pages/pdf-booklet.html @@ -120,7 +120,7 @@ class="text-2xl font-bold text-white mb-2" data-i18n="tools:pdfBooklet.name" > - Pdf Booklet Free Online - Fast & Secure + PDF Booklet

Rearrange pages for double-sided booklet printing. Fold and staple to @@ -142,7 +142,9 @@ > or drag and drop

-

A single PDF file

+

+ A single PDF file +

Your files never leave your device.

@@ -453,7 +455,10 @@
-

+

How It Works

@@ -465,7 +470,7 @@

Upload File

-

+

Click or drag and drop your file to begin

@@ -478,7 +483,9 @@

Process

-

Click the process button to start

+

+ Click the process button to start +

@@ -489,7 +496,9 @@

Download

-

Save your processed file instantly

+

+ Save your processed file instantly +

@@ -497,7 +506,10 @@
-

+

Related PDF Tools

@@ -541,7 +553,10 @@
-

+

Frequently Asked Questions

diff --git a/src/pages/pdf-layers.html b/src/pages/pdf-layers.html index e7a4e48..972336d 100644 --- a/src/pages/pdf-layers.html +++ b/src/pages/pdf-layers.html @@ -230,15 +230,19 @@ class="flex items-center gap-2 text-indigo-400 hover:text-indigo-300 mb-6 font-semibold" > - Back to Tools + Back to Tools -

- Pdf Layers Free Online - Fast & Secure +

+ PDF OCG

-

- Manage Optional Content Groups (OCG) in your PDF. View, toggle - visibility, add new layers, or delete existing ones. +

+ View, toggle, add, and delete OCG layers in your PDF.

- Click to select a PDF or drag - and drop + Click to select a PDF + or drag and drop

-

Single PDF file only

-

+

+ Single PDF file only +

+

Your files never leave your device.

@@ -371,7 +382,10 @@
-

+

How It Works

@@ -383,7 +397,7 @@

Upload File

-

+

Click or drag and drop your file to begin

@@ -396,7 +410,9 @@

Process

-

Click the process button to start

+

+ Click the process button to start +

@@ -407,7 +423,9 @@

Download

-

Save your processed file instantly

+

+ Save your processed file instantly +

@@ -415,7 +433,10 @@
-

+

Related PDF Tools

@@ -459,7 +480,10 @@
-

+

Frequently Asked Questions

diff --git a/src/pages/pdf-multi-tool.html b/src/pages/pdf-multi-tool.html index 68abb18..da35359 100644 --- a/src/pages/pdf-multi-tool.html +++ b/src/pages/pdf-multi-tool.html @@ -133,12 +133,14 @@
Bento PDF Logo - BentoPDF + {{#if brandName}}{{brandName}}{{else}}BentoPDF{{/if}} PDF Multi Tool - Pdf To Bmp Converter Free Online - Convert Files Fast + PDF to BMP

- Convert each page of a PDF file into a BMP image. + Convert each PDF page into a BMP image.

or drag and drop

-

A single PDF file

+

+ A single PDF file +

Your files never leave your device.

@@ -158,7 +160,7 @@
@@ -205,7 +207,10 @@
-

+

How It Works

@@ -217,7 +222,7 @@

Upload File

-

+

Click or drag and drop your file to begin

@@ -230,7 +235,9 @@

Process

-

Click the process button to start

+

+ Click the process button to start +

@@ -241,7 +248,9 @@

Download

-

Save your processed file instantly

+

+ Save your processed file instantly +

@@ -249,7 +258,10 @@
-

+

Related PDF Tools

@@ -293,7 +305,10 @@
-

+

Frequently Asked Questions

diff --git a/src/pages/pdf-to-csv.html b/src/pages/pdf-to-csv.html index 680e177..063c658 100644 --- a/src/pages/pdf-to-csv.html +++ b/src/pages/pdf-to-csv.html @@ -118,7 +118,7 @@ class="text-2xl font-bold text-white mb-2" data-i18n="tools:pdfToCsv.name" > - Pdf To Csv Converter Free Online - Convert Files Fast + PDF to CSV

Extract tables from PDF and convert to CSV format. @@ -138,7 +138,9 @@ > or drag and drop

-

PDF file

+

+ PDF file +

Your files never leave your device.

@@ -193,7 +195,10 @@
-

+

How It Works

@@ -205,7 +210,7 @@

Upload File

-

+

Click or drag and drop your file to begin

@@ -218,7 +223,9 @@

Process

-

Click the process button to start

+

+ Click the process button to start +

@@ -229,7 +236,9 @@

Download

-

Save your processed file instantly

+

+ Save your processed file instantly +

@@ -237,7 +246,10 @@
-

+

Related PDF Tools

@@ -281,7 +293,10 @@
-

+

Frequently Asked Questions

diff --git a/src/pages/pdf-to-docx.html b/src/pages/pdf-to-docx.html index b663cd8..5b890f2 100644 --- a/src/pages/pdf-to-docx.html +++ b/src/pages/pdf-to-docx.html @@ -119,12 +119,14 @@ > -

- PDF to Word Converter Free - Convert PDF to DOCX +

+ PDF to Word

-

- Convert PDF files to editable Word documents. Preserves text, - formatting, and layout. +

+ Convert PDF files to editable Word documents.

- Click to select files + Click to select files or drag and drop

-

One or more PDF files

+

+ One or more PDF files +

Your files never leave your device.

@@ -217,7 +226,10 @@
-

+

How It Works

@@ -229,7 +241,7 @@

Upload File

-

+

Click or drag and drop your file to begin

@@ -242,7 +254,9 @@

Process

-

Click the process button to start

+

+ Click the process button to start +

@@ -253,7 +267,9 @@

Download

-

Save your processed file instantly

+

+ Save your processed file instantly +

@@ -261,7 +277,10 @@
-

+

Related PDF Tools

@@ -305,7 +324,10 @@
-

+

Frequently Asked Questions

diff --git a/src/pages/pdf-to-excel.html b/src/pages/pdf-to-excel.html index 88f2489..bcb3921 100644 --- a/src/pages/pdf-to-excel.html +++ b/src/pages/pdf-to-excel.html @@ -121,7 +121,7 @@ class="text-2xl font-bold text-white mb-2" data-i18n="tools:pdfToExcel.name" > - PDF to Excel Converter Free - Extract Tables + PDF to Excel

Extract tables from PDF and convert to Excel (XLSX) format. @@ -141,7 +141,9 @@ > or drag and drop

-

PDF file

+

+ PDF file +

Your files never leave your device.

@@ -196,7 +198,10 @@
-

+

How It Works

@@ -208,7 +213,7 @@

Upload File

-

+

Click or drag and drop your file to begin

@@ -221,7 +226,9 @@

Process

-

Click the process button to start

+

+ Click the process button to start +

@@ -232,7 +239,9 @@

Download

-

Save your processed file instantly

+

+ Save your processed file instantly +

@@ -240,7 +249,10 @@
-

+

Related PDF Tools

@@ -284,7 +296,10 @@
-

+

Frequently Asked Questions

diff --git a/src/pages/pdf-to-greyscale.html b/src/pages/pdf-to-greyscale.html index 34e0240..09035ed 100644 --- a/src/pages/pdf-to-greyscale.html +++ b/src/pages/pdf-to-greyscale.html @@ -134,10 +134,10 @@ class="text-2xl font-bold text-white mb-2" data-i18n="tools:pdfToGreyscale.name" > - Pdf To Greyscale Converter Free Online - Convert Files Fast + PDF to Greyscale

- Convert all colors in a PDF to black and white. + Convert all colors to black and white.

or drag and drop

-

A single PDF file

+

+ A single PDF file +

Your files never leave your device.

@@ -219,7 +221,10 @@
-

+

How It Works

@@ -231,7 +236,7 @@

Upload File

-

+

Click or drag and drop your file to begin

@@ -244,7 +249,9 @@

Process

-

Click the process button to start

+

+ Click the process button to start +

@@ -255,7 +262,9 @@

Download

-

Save your processed file instantly

+

+ Save your processed file instantly +

@@ -263,7 +272,10 @@
-

+

Related PDF Tools

@@ -307,7 +319,10 @@
-

+

Frequently Asked Questions

diff --git a/src/pages/pdf-to-jpg.html b/src/pages/pdf-to-jpg.html index 971ec50..dcee91c 100644 --- a/src/pages/pdf-to-jpg.html +++ b/src/pages/pdf-to-jpg.html @@ -123,10 +123,10 @@ class="text-2xl font-bold text-white mb-2" data-i18n="tools:pdfToJpg.name" > - PDF to JPG Converter Free - Convert PDF to Images + PDF to JPG

- Convert each page of a PDF file into a high-quality JPG image. + Convert each PDF page into a JPG image.

or drag and drop

-

A single PDF file

+

+ A single PDF file +

Your files never leave your device.

@@ -188,7 +190,7 @@
@@ -235,7 +237,10 @@
-

+

How It Works

@@ -300,7 +305,10 @@
-

+

Related PDF Tools

@@ -344,7 +352,10 @@
-

+

Frequently Asked Questions

diff --git a/src/pages/pdf-to-json.html b/src/pages/pdf-to-json.html index ea1fe27..4a47981 100644 --- a/src/pages/pdf-to-json.html +++ b/src/pages/pdf-to-json.html @@ -112,11 +112,10 @@ class="text-2xl font-bold text-white mb-2" data-i18n="tools:pdfToJson.name" > - Pdf To Json Converter Free Online - Convert Files Fast + PDF to JSON

- Upload multiple PDF files to convert them all to JSON format. Files - will be downloaded as a ZIP archive. + Convert PDF files to JSON format.

@@ -129,10 +128,17 @@ class="w-10 h-10 mb-3 text-gray-400" >

- Click to select files + Click to select files or drag and drop

-

Multiple PDF files

+

+ Multiple PDF files +

-

+

How It Works

@@ -179,7 +188,7 @@

Upload File

-

+

Click or drag and drop your file to begin

@@ -192,7 +201,9 @@

Process

-

Click the process button to start

+

+ Click the process button to start +

@@ -203,7 +214,9 @@

Download

-

Save your processed file instantly

+

+ Save your processed file instantly +

@@ -211,7 +224,10 @@
-

+

Related PDF Tools

@@ -255,7 +271,10 @@
-

+

Frequently Asked Questions

diff --git a/src/pages/pdf-to-markdown.html b/src/pages/pdf-to-markdown.html index 20e1136..f697c97 100644 --- a/src/pages/pdf-to-markdown.html +++ b/src/pages/pdf-to-markdown.html @@ -123,15 +123,19 @@ class="flex items-center gap-2 text-indigo-400 hover:text-indigo-300 mb-6 font-semibold" > - Back to Tools + Back to Tools -

- Pdf To Markdown Converter Free Online - Convert Files Fast +

+ PDF to Markdown

-

- Convert PDF files to Markdown format. Preserves text structure with - optional image embedding. +

+ Convert PDF text and tables to Markdown format.

- Click to select files - or drag and drop + Click to select files + or drag and drop

-

One or more PDF files

-

+

+ One or more PDF files +

+

Your files never leave your device.

@@ -232,7 +243,10 @@
-

+

How It Works

@@ -244,7 +258,7 @@

Upload File

-

+

Click or drag and drop your file to begin

@@ -257,7 +271,9 @@

Process

-

Click the process button to start

+

+ Click the process button to start +

@@ -268,7 +284,9 @@

Download

-

Save your processed file instantly

+

+ Save your processed file instantly +

@@ -276,7 +294,10 @@
-

+

Related PDF Tools

@@ -320,7 +341,10 @@
-

+

Frequently Asked Questions

diff --git a/src/pages/pdf-to-pdfa.html b/src/pages/pdf-to-pdfa.html index 6eae079..668d5f7 100644 --- a/src/pages/pdf-to-pdfa.html +++ b/src/pages/pdf-to-pdfa.html @@ -116,12 +116,14 @@ -

- Pdf To Pdfa Converter Free Online - Convert Files Fast +

+ PDF to PDF/A

-

- Convert your PDF documents to PDF/A format for long-term archiving and - preservation. +

+ Convert PDF to PDF/A for long-term archiving.

- Click to select files + Click to select files or drag and drop

-

One or more PDF files

+

+ One or more PDF files +

Your files never leave your device.

@@ -191,6 +200,29 @@
+
+ +
+ +

+ Converts the PDF to images first, ensuring better PDF/A + compliance. Recommended if validation fails on the normal + conversion. +

+
+
+ @@ -241,7 +273,10 @@
-

+

How It Works

@@ -253,7 +288,7 @@

Upload File

-

+

Click or drag and drop your file to begin

@@ -266,7 +301,9 @@

Process

-

Click the process button to start

+

+ Click the process button to start +

@@ -277,7 +314,9 @@

Download

-

Save your processed file instantly

+

+ Save your processed file instantly +

@@ -285,7 +324,10 @@
-

+

Related PDF Tools

@@ -329,7 +371,10 @@
-

+

Frequently Asked Questions

diff --git a/src/pages/pdf-to-png.html b/src/pages/pdf-to-png.html index 415f1d1..0853293 100644 --- a/src/pages/pdf-to-png.html +++ b/src/pages/pdf-to-png.html @@ -120,11 +120,10 @@ class="text-2xl font-bold text-white mb-2" data-i18n="tools:pdfToPng.name" > - PDF to PNG Converter Free - High Quality Images + PDF to PNG

- Convert each page of a PDF file into a high-quality PNG image with - transparency support. + Convert each PDF page into a PNG image.

or drag and drop

-

A single PDF file

+

+ A single PDF file +

Your files never leave your device.

@@ -186,7 +187,7 @@
@@ -233,7 +234,10 @@
-

+

How It Works

@@ -245,7 +249,7 @@

Upload File

-

+

Click or drag and drop your file to begin

@@ -258,7 +262,9 @@

Process

-

Click the process button to start

+

+ Click the process button to start +

@@ -269,7 +275,9 @@

Download

-

Save your processed file instantly

+

+ Save your processed file instantly +

@@ -277,7 +285,10 @@
-

+

Related PDF Tools

@@ -321,7 +332,10 @@
-

+

Frequently Asked Questions

diff --git a/src/pages/pdf-to-svg.html b/src/pages/pdf-to-svg.html index 4ddb57e..86d7c77 100644 --- a/src/pages/pdf-to-svg.html +++ b/src/pages/pdf-to-svg.html @@ -120,7 +120,7 @@ class="text-2xl font-bold text-white mb-2" data-i18n="tools:pdfToSvg.name" > - Pdf To Svg Converter Free Online - Convert Files Fast + PDF to SVG

Convert each page of a PDF file into a scalable vector graphic (SVG) @@ -142,7 +142,9 @@ > or drag and drop

-

PDF files

+

+ PDF files +

Your files never leave your device.

@@ -224,7 +226,10 @@
-

+

How It Works

@@ -236,7 +241,7 @@

Upload File

-

+

Click or drag and drop your file to begin

@@ -249,7 +254,9 @@

Process

-

Click the process button to start

+

+ Click the process button to start +

@@ -260,7 +267,9 @@

Download

-

Save your processed file instantly

+

+ Save your processed file instantly +

@@ -268,7 +277,10 @@
-

+

Related PDF Tools

@@ -312,7 +324,10 @@
-

+

Frequently Asked Questions

diff --git a/src/pages/pdf-to-text.html b/src/pages/pdf-to-text.html index bf8bac6..ede5643 100644 --- a/src/pages/pdf-to-text.html +++ b/src/pages/pdf-to-text.html @@ -120,11 +120,11 @@ class="text-2xl font-bold text-white mb-2" data-i18n="tools:pdfToText.name" > - PDF to Text Converter Free - Extract Text Online + PDF to Text

- Extract all text from PDF files and save as plain text (.txt). - Supports multiple files. + Extract text from PDF files and save as plain text (.txt). Supports + multiple files.

or drag and drop

-

PDF files

+

+ PDF files +

-

+

How It Works

@@ -264,7 +272,7 @@

Upload File

-

+

Click or drag and drop your file to begin

@@ -277,7 +285,9 @@

Process

-

Click the process button to start

+

+ Click the process button to start +

@@ -288,7 +298,9 @@

Download

-

Save your processed file instantly

+

+ Save your processed file instantly +

@@ -296,7 +308,10 @@
-

+

Related PDF Tools

@@ -340,7 +355,10 @@
-

+

Frequently Asked Questions

diff --git a/src/pages/pdf-to-tiff.html b/src/pages/pdf-to-tiff.html index 74006d2..f4292dc 100644 --- a/src/pages/pdf-to-tiff.html +++ b/src/pages/pdf-to-tiff.html @@ -118,10 +118,10 @@ class="text-2xl font-bold text-white mb-2" data-i18n="tools:pdfToTiff.name" > - Pdf To Tiff Converter Free Online - Convert Files Fast + PDF to TIFF

- Convert each page of a PDF file into a TIFF image. + Convert each PDF page into a TIFF image.

or drag and drop

-

A single PDF file

+

+ A single PDF file +

Your files never leave your device.

@@ -153,7 +155,7 @@
@@ -205,7 +207,10 @@
-

+

How It Works

@@ -217,7 +222,7 @@

Upload File

-

+

Click or drag and drop your file to begin

@@ -230,7 +235,9 @@

Process

-

Click the process button to start

+

+ Click the process button to start +

@@ -241,7 +248,9 @@

Download

-

Save your processed file instantly

+

+ Save your processed file instantly +

@@ -249,7 +258,10 @@
-

+

Related PDF Tools

@@ -293,7 +305,10 @@
-

+

Frequently Asked Questions

diff --git a/src/pages/pdf-to-webp.html b/src/pages/pdf-to-webp.html index b561efa..0501482 100644 --- a/src/pages/pdf-to-webp.html +++ b/src/pages/pdf-to-webp.html @@ -120,11 +120,10 @@ class="text-2xl font-bold text-white mb-2" data-i18n="tools:pdfToWebp.name" > - Pdf To Webp Converter Free Online - Convert Files Fast + PDF to WebP

- Convert each page of a PDF file into a modern WebP image format with - smaller file sizes. + Convert each PDF page into a WebP image.

or drag and drop

-

A single PDF file

+

+ A single PDF file +

Your files never leave your device.

@@ -186,7 +187,7 @@
@@ -233,7 +234,10 @@
-

+

How It Works

@@ -245,7 +249,7 @@

Upload File

-

+

Click or drag and drop your file to begin

@@ -258,7 +262,9 @@

Process

-

Click the process button to start

+

+ Click the process button to start +

@@ -269,7 +275,9 @@

Download

-

Save your processed file instantly

+

+ Save your processed file instantly +

@@ -277,7 +285,10 @@
-

+

Related PDF Tools

@@ -321,7 +332,10 @@
-

+

Frequently Asked Questions

diff --git a/src/pages/pdf-to-zip.html b/src/pages/pdf-to-zip.html index ef41ddc..ef07797 100644 --- a/src/pages/pdf-to-zip.html +++ b/src/pages/pdf-to-zip.html @@ -120,11 +120,10 @@ class="text-2xl font-bold text-white mb-2" data-i18n="tools:pdfsToZip.name" > - Pdf To Zip Converter Free Online - Convert Files Fast + PDFs to ZIP

- Package multiple PDF files into a single ZIP archive for easy sharing - and storage. + Package multiple PDF files into a ZIP archive.

- Click to select files + Click to select files or drag and drop

-

Multiple PDF files

+

+ Multiple PDF files +

Your files never leave your device.

@@ -227,7 +233,10 @@
-

+

How It Works

@@ -239,7 +248,7 @@

Upload File

-

+

Click or drag and drop your file to begin

@@ -252,7 +261,9 @@

Process

-

Click the process button to start

+

+ Click the process button to start +

@@ -263,7 +274,9 @@

Download

-

Save your processed file instantly

+

+ Save your processed file instantly +

@@ -271,7 +284,10 @@
-

+

Related PDF Tools

@@ -315,7 +331,10 @@
-

+

Frequently Asked Questions

diff --git a/src/pages/pdf-workflow.html b/src/pages/pdf-workflow.html new file mode 100644 index 0000000..fd323f8 --- /dev/null +++ b/src/pages/pdf-workflow.html @@ -0,0 +1,578 @@ + + + + + + + PDF Workflow Builder - Visual Pipeline | BentoPDF + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {{> navbar }} + + +
+ + + + + +
+ +
+ + + +
+ + + + +
+ + +
+ + + +
+ + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/pages/png-to-pdf.html b/src/pages/png-to-pdf.html index e162b9c..c3562ad 100644 --- a/src/pages/png-to-pdf.html +++ b/src/pages/png-to-pdf.html @@ -120,10 +120,10 @@ class="text-2xl font-bold text-white mb-2" data-i18n="tools:pngToPdf.name" > - PNG to PDF Converter Free - Convert Images Fast + PNG to PDF

- Convert one or more PNG images into a single PDF file. + Create a PDF from one or more PNG images.

@@ -142,7 +142,9 @@ > or drag and drop

-

PNG Images

+

+ PNG Images +

Your files never leave your device.

@@ -249,7 +251,10 @@
-

+

How It Works

@@ -261,7 +266,7 @@

Upload File

-

+

Click or drag and drop your file to begin

@@ -274,7 +279,9 @@

Process

-

Click the process button to start

+

+ Click the process button to start +

@@ -285,7 +292,9 @@

Download

-

Save your processed file instantly

+

+ Save your processed file instantly +

@@ -293,7 +302,10 @@
-

+

Related PDF Tools

@@ -337,7 +349,10 @@
-

+

Frequently Asked Questions

diff --git a/src/pages/posterize-pdf.html b/src/pages/posterize-pdf.html index 3c13eff..5527553 100644 --- a/src/pages/posterize-pdf.html +++ b/src/pages/posterize-pdf.html @@ -120,11 +120,10 @@ class="text-2xl font-bold text-white mb-2" data-i18n="tools:posterizePdf.name" > - Posterize Pdf Free Online - Fast & Secure + Posterize PDF

- Split pages into multiple smaller sheets to print as a poster. - Navigate the preview and see the grid update based on your settings. + Split a large page into multiple smaller pages.

- Click to select PDF + Click to select PDF or drag and drop

@@ -410,7 +411,10 @@

-

+

How It Works

@@ -422,7 +426,7 @@

Upload File

-

+

Click or drag and drop your file to begin

@@ -435,7 +439,9 @@

Process

-

Click the process button to start

+

+ Click the process button to start +

@@ -446,7 +452,9 @@

Download

-

Save your processed file instantly

+

+ Save your processed file instantly +

@@ -454,7 +462,10 @@
-

+

Related PDF Tools

@@ -498,7 +509,10 @@
-

+

Frequently Asked Questions

diff --git a/src/pages/powerpoint-to-pdf.html b/src/pages/powerpoint-to-pdf.html index ef49283..72fc9c5 100644 --- a/src/pages/powerpoint-to-pdf.html +++ b/src/pages/powerpoint-to-pdf.html @@ -134,7 +134,7 @@ class="text-2xl font-bold text-white mb-2" data-i18n="tools:powerpointToPdf.name" > - PowerPoint to PDF Converter - Convert PPT Free + PowerPoint to PDF

-

+

How It Works

@@ -261,7 +264,7 @@

Upload File

-

+

Click or drag and drop your file to begin

@@ -274,7 +277,9 @@

Process

-

Click the process button to start

+

+ Click the process button to start +

@@ -285,7 +290,9 @@

Download

-

Save your processed file instantly

+

+ Save your processed file instantly +

@@ -293,7 +300,10 @@
-

+

Related PDF Tools

@@ -337,7 +347,10 @@
-

+

Frequently Asked Questions

diff --git a/src/pages/prepare-pdf-for-ai.html b/src/pages/prepare-pdf-for-ai.html index a237b1b..1c40a73 100644 --- a/src/pages/prepare-pdf-for-ai.html +++ b/src/pages/prepare-pdf-for-ai.html @@ -125,15 +125,22 @@ class="flex items-center gap-2 text-indigo-400 hover:text-indigo-300 mb-6 font-semibold" > - Back to Tools + Back to Tools -

- Prepare Pdf For Ai Free Online - Fast & Secure +

+ Prepare PDF for AI

-

- Extract PDF content as LlamaIndex-compatible JSON documents. Perfect - for RAG pipelines, LangChain, and other LLM frameworks. +

+ Extract PDF content as LlamaIndex JSON for RAG/LLM pipelines.

- Click to select files or drag - and drop + Click to select files + or drag and drop

-

One or more PDF files

-

+

+ One or more PDF files +

+

Your files never leave your device.

@@ -246,7 +260,10 @@
-

+

How It Works

@@ -258,7 +275,7 @@

Upload File

-

+

Click or drag and drop your file to begin

@@ -271,7 +288,9 @@

Process

-

Click the process button to start

+

+ Click the process button to start +

@@ -282,7 +301,9 @@

Download

-

Save your processed file instantly

+

+ Save your processed file instantly +

@@ -290,7 +311,10 @@
-

+

Related PDF Tools

@@ -334,7 +358,10 @@
-

+

Frequently Asked Questions

diff --git a/src/pages/psd-to-pdf.html b/src/pages/psd-to-pdf.html index 8ca7648..cd5a527 100644 --- a/src/pages/psd-to-pdf.html +++ b/src/pages/psd-to-pdf.html @@ -120,7 +120,7 @@ class="text-2xl font-bold text-white mb-2" data-i18n="tools:psdToPdf.name" > - Psd To Pdf Converter Free Online - Convert Files Fast + PSD to PDF

Convert Adobe Photoshop (PSD) files to PDF format. Supports multiple @@ -137,7 +137,9 @@ class="w-10 h-10 mb-3 text-gray-400" >

- Click to select files + Click to select files or drag and drop

-

+

How It Works

@@ -236,7 +241,7 @@

Upload File

-

+

Click or drag and drop your file to begin

@@ -249,7 +254,9 @@

Process

-

Click the process button to start

+

+ Click the process button to start +

@@ -260,7 +267,9 @@

Download

-

Save your processed file instantly

+

+ Save your processed file instantly +

@@ -268,7 +277,10 @@
-

+

Related PDF Tools

@@ -312,7 +324,10 @@
-

+

Frequently Asked Questions

diff --git a/src/pages/pub-to-pdf.html b/src/pages/pub-to-pdf.html index df7c35e..e518cf5 100644 --- a/src/pages/pub-to-pdf.html +++ b/src/pages/pub-to-pdf.html @@ -120,7 +120,7 @@ class="text-2xl font-bold text-white mb-2" data-i18n="tools:pubToPdf.name" > - Pub To Pdf Converter Free Online - Convert Files Fast + PUB to PDF

Convert Microsoft Publisher (PUB) files to PDF format. Supports @@ -137,7 +137,9 @@ class="w-10 h-10 mb-3 text-gray-400" >

- Click to select files + Click to select files or drag and drop

-

+

How It Works

@@ -246,7 +251,7 @@

Upload File

-

+

Click or drag and drop your file to begin

@@ -259,7 +264,9 @@

Process

-

Click the process button to start

+

+ Click the process button to start +

@@ -270,7 +277,9 @@

Download

-

Save your processed file instantly

+

+ Save your processed file instantly +

@@ -278,7 +287,10 @@
-

+

Related PDF Tools

@@ -322,7 +334,10 @@
-

+

Frequently Asked Questions

diff --git a/src/pages/rasterize-pdf.html b/src/pages/rasterize-pdf.html index 1eea341..bd464c6 100644 --- a/src/pages/rasterize-pdf.html +++ b/src/pages/rasterize-pdf.html @@ -111,15 +111,20 @@ class="flex items-center gap-2 text-indigo-400 hover:text-indigo-300 mb-6 font-semibold" > - Back to Tools + Back to Tools -

- Rasterize Pdf Free Online - Fast & Secure +

+ Rasterize PDF

-

- Convert vector graphics and text to images. Useful for flattening - layers, removing selectable text, or creating print-ready files. +

+ Convert PDF to image-based PDF. Flatten layers and remove selectable + text.

- Click to select files or drag - and drop + Click to select files + or drag and drop

-

One or more PDF files

-

+

+ One or more PDF files +

+

Your files never leave your device.

@@ -256,7 +268,10 @@
-

+

How It Works

@@ -268,7 +283,7 @@

Upload File

-

+

Click or drag and drop your file to begin

@@ -281,7 +296,9 @@

Process

-

Click the process button to start

+

+ Click the process button to start +

@@ -292,7 +309,9 @@

Download

-

Save your processed file instantly

+

+ Save your processed file instantly +

@@ -300,7 +319,10 @@
-

+

Related PDF Tools

@@ -344,7 +366,10 @@
-

+

Frequently Asked Questions

diff --git a/src/pages/remove-annotations.html b/src/pages/remove-annotations.html index 0c42400..1e7b5f1 100644 --- a/src/pages/remove-annotations.html +++ b/src/pages/remove-annotations.html @@ -134,13 +134,13 @@ class="text-2xl font-bold text-white mb-2" data-i18n="tools:removeAnnotations.name" > - Remove Annotations Free Online - Fast & Secure + Remove Annotations

- Remove all comments, highlights, and markup from your PDF document. + Strip comments, highlights, and links.

or drag and drop

-

PDF file

+

+ PDF file +

Your files never leave your device.

@@ -230,7 +232,10 @@
-

+

How It Works

@@ -242,7 +247,7 @@

Upload File

-

+

Click or drag and drop your file to begin

@@ -255,7 +260,9 @@

Process

-

Click the process button to start

+

+ Click the process button to start +

@@ -266,7 +273,9 @@

Download

-

Save your processed file instantly

+

+ Save your processed file instantly +

@@ -274,7 +283,10 @@
-

+

Related PDF Tools

@@ -318,7 +330,10 @@
-

+

Frequently Asked Questions

diff --git a/src/pages/remove-blank-pages.html b/src/pages/remove-blank-pages.html index 4c0a787..d93b54f 100644 --- a/src/pages/remove-blank-pages.html +++ b/src/pages/remove-blank-pages.html @@ -134,14 +134,13 @@ class="text-2xl font-bold text-white mb-2" data-i18n="tools:removeBlankPages.name" > - Remove Blank Pages Free Online - Fast & Secure + Remove Blank Pages

- Automatically detect and remove all blank pages from your PDF - document. + Automatically detect and delete blank pages.

or drag and drop

-

PDF file

+

+ PDF file +

Your files never leave your device.

@@ -191,8 +192,12 @@ step="5" class="w-full h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer" /> -

- Higher values detect more pages as blank +

+ Higher = stricter, only purely blank pages. Lower = allows pages + with some content.

Process

-

Click the process button to start

+

+ Click the process button to start +

@@ -299,7 +309,9 @@

Download

-

Save your processed file instantly

+

+ Save your processed file instantly +

@@ -307,7 +319,10 @@
-

+

Related PDF Tools

@@ -351,7 +366,10 @@
-

+

Frequently Asked Questions

diff --git a/src/pages/remove-metadata.html b/src/pages/remove-metadata.html index 570027c..5ac2bff 100644 --- a/src/pages/remove-metadata.html +++ b/src/pages/remove-metadata.html @@ -132,11 +132,10 @@ class="text-2xl font-bold text-white mb-2" data-i18n="tools:removeMetadata.name" > - Remove Metadata Free Online - Fast & Secure + Remove Metadata

- Strip all hidden data from your PDF including author, title, creation - date, and XMP metadata. + Strip hidden data from your PDF.

- Click to select PDF + Click to select PDF or drag and drop

@@ -235,7 +236,10 @@

-

+

How It Works

@@ -247,7 +251,7 @@

Upload File

-

+

Click or drag and drop your file to begin

@@ -260,7 +264,9 @@

Process

-

Click the process button to start

+

+ Click the process button to start +

@@ -271,7 +277,9 @@

Download

-

Save your processed file instantly

+

+ Save your processed file instantly +

@@ -279,7 +287,10 @@
-

+

Related PDF Tools

@@ -323,7 +334,10 @@
-

+

Frequently Asked Questions

diff --git a/src/pages/remove-restrictions.html b/src/pages/remove-restrictions.html index 590e3c3..92bb556 100644 --- a/src/pages/remove-restrictions.html +++ b/src/pages/remove-restrictions.html @@ -136,14 +136,14 @@ class="text-2xl font-bold text-white mb-2" data-i18n="tools:removeRestrictions.name" > - Remove Restrictions Free Online - Fast & Secure + Remove Restrictions

- Remove password protection and security restrictions from PDF files. - Make PDFs fully editable and printable. + Remove password protection and security restrictions associated with + digitally signed PDF files.

- Click to select PDF + Click to select PDF or drag and drop

@@ -259,7 +261,10 @@

-

+

How It Works

@@ -271,7 +276,7 @@

Upload File

-

+

Click or drag and drop your file to begin

@@ -284,7 +289,9 @@

Process

-

Click the process button to start

+

+ Click the process button to start +

@@ -295,7 +302,9 @@

Download

-

Save your processed file instantly

+

+ Save your processed file instantly +

@@ -303,7 +312,10 @@
-

+

Related PDF Tools

@@ -347,7 +359,10 @@
-

+

Frequently Asked Questions

diff --git a/src/pages/repair-pdf.html b/src/pages/repair-pdf.html index 30b8707..78a2e01 100644 --- a/src/pages/repair-pdf.html +++ b/src/pages/repair-pdf.html @@ -115,7 +115,7 @@ class="text-2xl font-bold text-white mb-2" data-i18n="tools:repairPdf.name" > - Repair Pdf Free Online - Fast & Secure + Repair PDF

Recover data from corrupted or damaged PDF files. @@ -230,7 +230,10 @@

-

+

How It Works

@@ -242,7 +245,7 @@

Upload File

-

+

Click or drag and drop your file to begin

@@ -255,7 +258,9 @@

Process

-

Click the process button to start

+

+ Click the process button to start +

@@ -266,7 +271,9 @@

Download

-

Save your processed file instantly

+

+ Save your processed file instantly +

@@ -274,7 +281,10 @@
-

+

Related PDF Tools

@@ -318,7 +328,10 @@
-

+

Frequently Asked Questions

diff --git a/src/pages/reverse-pages.html b/src/pages/reverse-pages.html index ae0f155..5b79b1d 100644 --- a/src/pages/reverse-pages.html +++ b/src/pages/reverse-pages.html @@ -120,11 +120,10 @@ class="text-2xl font-bold text-white mb-2" data-i18n="tools:reversePages.name" > - Reverse Pages Free Online - Fast & Secure + Reverse Pages

- Flip the order of all pages in your PDF document. The last page - becomes the first, and so on. + Flip the order of all pages in your document.

@@ -138,10 +137,17 @@ class="w-10 h-10 mb-3 text-gray-400" >

- Click to select files + Click to select files or drag and drop

-

PDF files (one or more)

+

+ PDF files (one or more) +

Your files never leave your device.

@@ -226,7 +232,10 @@
-

+

How It Works

@@ -238,7 +247,7 @@

Upload File

-

+

Click or drag and drop your file to begin

@@ -251,7 +260,9 @@

Process

-

Click the process button to start

+

+ Click the process button to start +

@@ -262,7 +273,9 @@

Download

-

Save your processed file instantly

+

+ Save your processed file instantly +

@@ -270,7 +283,10 @@
-

+

Related PDF Tools

@@ -314,7 +330,10 @@
-

+

Frequently Asked Questions

diff --git a/src/pages/rotate-custom.html b/src/pages/rotate-custom.html index 842c27b..258c3c1 100644 --- a/src/pages/rotate-custom.html +++ b/src/pages/rotate-custom.html @@ -119,11 +119,10 @@ class="text-2xl font-bold text-white mb-2" data-i18n="tools:rotateCustom.name" > - Rotate Custom Free Online - Fast & Secure + Rotate by Custom Degrees

- Rotate pages by any custom angle. Enter degrees (positive = - counter-clockwise, negative = clockwise). + Rotate pages by any custom angle.

- Click to select a file + Click to select a file or drag and drop

-

A single PDF file

-

+

+ A single PDF file +

+

Your files never leave your device.

@@ -243,7 +246,10 @@
-

+

How It Works

@@ -255,7 +261,7 @@

Upload File

-

+

Click or drag and drop your file to begin

@@ -268,7 +274,9 @@

Process

-

Click the process button to start

+

+ Click the process button to start +

@@ -279,7 +287,9 @@

Download

-

Save your processed file instantly

+

+ Save your processed file instantly +

@@ -287,7 +297,10 @@
-

+

Related PDF Tools

@@ -331,7 +344,10 @@
-

+

Frequently Asked Questions

diff --git a/src/pages/rotate-pdf.html b/src/pages/rotate-pdf.html index bad8ce5..eb85177 100644 --- a/src/pages/rotate-pdf.html +++ b/src/pages/rotate-pdf.html @@ -120,11 +120,10 @@ class="text-2xl font-bold text-white mb-2" data-i18n="tools:rotatePdf.name" > - Rotate PDF Pages Free - Turn PDFs Online + Rotate PDF

- Rotate individual pages or all pages at once. Click on page thumbnails - to rotate them. + Turn pages in 90-degree increments.

or drag and drop

-

A single PDF file

+

+ A single PDF file +

Your files never leave your device.

@@ -245,7 +246,10 @@
-

+

How It Works

@@ -257,7 +261,7 @@

Upload File

-

+

Click or drag and drop your file to begin

@@ -270,7 +274,9 @@

Process

-

Click the process button to start

+

+ Click the process button to start +

@@ -281,7 +287,9 @@

Download

-

Save your processed file instantly

+

+ Save your processed file instantly +

@@ -289,7 +297,10 @@
-

+

Related PDF Tools

@@ -333,7 +344,10 @@
-

+

Frequently Asked Questions

diff --git a/src/pages/rtf-to-pdf.html b/src/pages/rtf-to-pdf.html index 85c062e..656b59b 100644 --- a/src/pages/rtf-to-pdf.html +++ b/src/pages/rtf-to-pdf.html @@ -116,12 +116,14 @@ -

- Rtf To Pdf Converter Free Online - Convert Files Fast +

+ RTF to PDF

-

- Convert RTF (Rich Text Format) document files to PDF format. Supports - multiple files. +

+ Convert Rich Text Format documents to PDF. Supports multiple files.

- Click to select files + Click to select files or drag and drop

-

One or more RTF files

+

+ One or more RTF files +

Your files never leave your device.

@@ -219,7 +228,10 @@
-

+

How It Works

@@ -231,7 +243,7 @@

Upload File

-

+

Click or drag and drop your file to begin

@@ -244,7 +256,9 @@

Process

-

Click the process button to start

+

+ Click the process button to start +

@@ -255,7 +269,9 @@

Download

-

Save your processed file instantly

+

+ Save your processed file instantly +

@@ -263,7 +279,10 @@
-

+

Related PDF Tools

@@ -307,7 +326,10 @@
-

+

Frequently Asked Questions

diff --git a/src/pages/sanitize-pdf.html b/src/pages/sanitize-pdf.html index 965fac8..559f925 100644 --- a/src/pages/sanitize-pdf.html +++ b/src/pages/sanitize-pdf.html @@ -120,11 +120,10 @@ class="text-2xl font-bold text-white mb-2" data-i18n="tools:sanitizePdf.name" > - Sanitize Pdf Free Online - Fast & Secure + Sanitize PDF

- Remove sensitive content from PDF files. Clean metadata, JavaScript, - embedded files, and more. + Remove metadata, annotations, scripts, and more.

- Click to select PDF + Click to select PDF or drag and drop

@@ -322,7 +323,10 @@

-

+

How It Works

@@ -334,7 +338,7 @@

Upload File

-

+

Click or drag and drop your file to begin

@@ -347,7 +351,9 @@

Process

-

Click the process button to start

+

+ Click the process button to start +

@@ -358,7 +364,9 @@

Download

-

Save your processed file instantly

+

+ Save your processed file instantly +

@@ -366,7 +374,10 @@
-

+

Related PDF Tools

@@ -410,7 +421,10 @@
-

+

Frequently Asked Questions

diff --git a/src/pages/scanner-effect.html b/src/pages/scanner-effect.html new file mode 100644 index 0000000..487d379 --- /dev/null +++ b/src/pages/scanner-effect.html @@ -0,0 +1,670 @@ + + + + + + + Scanner Effect Online Free - Scanner Effect Tool | BentoPDF + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Scanner Effect - BentoPDF + + + + + + + + + + + + + + + {{> navbar }} + +
+
+ + +

+ Scanner Effect +

+

+ Make your PDF look like a scanned document. +

+ +
+
+ +

+ Click to select a file + or drag and drop +

+

+ A single PDF file +

+

+ Your files never leave your device. +

+
+ +
+ +
+ + +
+
+ + + + + +
+

+ How It Works +

+
+
+
+ 1 +
+
+

Upload File

+

+ Click or drag and drop your PDF file to begin +

+
+
+
+
+ 2 +
+
+

+ Adjust Settings +

+

+ Customize the scanner effect with real-time preview +

+
+
+
+
+ 3 +
+
+

Download

+

Save your scanned-looking PDF instantly

+
+
+
+
+ +
+

+ Related PDF Tools +

+ +
+ +
+

+ Frequently Asked Questions +

+
+
+ + Is scanner effect really free? + + +

+ Yes! BentoPDF is 100% free with no hidden fees, no signup required, + and unlimited file processing. +

+
+
+ + Are my files private and secure? + + +

+ Absolutely! All processing happens in your browser. Your files never + leave your device, ensuring complete privacy. +

+
+
+ + Is there a file size limit? + + +

+ No! Process files of any size, as many times as you want, completely + free. +

+
+
+
+ {{> footer }} + + + + + + + + + + + + + + + diff --git a/src/pages/sign-pdf.html b/src/pages/sign-pdf.html index 589ec9d..5d0615f3 100644 --- a/src/pages/sign-pdf.html +++ b/src/pages/sign-pdf.html @@ -123,12 +123,10 @@ class="text-2xl font-bold text-white mb-2" data-i18n="tools:signPdf.name" > - Sign PDF Free Online - Add Digital Signature + Sign PDF

- Upload a PDF to sign it using the built-in PDF.js viewer. Look for the - signature / pen tool in the toolbar to add your - signature. + Draw, type, or upload your signature.

@@ -147,7 +145,12 @@ > or drag and drop

-

PDF Documents

+

+ PDF Documents +

Your files never leave your device.

@@ -240,7 +243,10 @@
-

+

How It Works

@@ -252,7 +258,7 @@

Upload File

-

+

Click or drag and drop your file to begin

@@ -265,7 +271,9 @@

Process

-

Click the process button to start

+

+ Click the process button to start +

@@ -276,7 +284,9 @@

Download

-

Save your processed file instantly

+

+ Save your processed file instantly +

@@ -284,7 +294,10 @@
-

+

Related PDF Tools

@@ -328,7 +341,10 @@
-

+

Frequently Asked Questions

diff --git a/src/pages/split-pdf.html b/src/pages/split-pdf.html index 1b71ff7..5d47dcc 100644 --- a/src/pages/split-pdf.html +++ b/src/pages/split-pdf.html @@ -123,10 +123,10 @@ class="text-2xl font-bold text-white mb-2" data-i18n="tools:splitPdf.name" > - Split PDF Online Free - Extract Pages Easily + Split PDF

- Extract pages from a PDF using various methods. + Extract a range of pages into a new PDF.

@@ -145,7 +145,9 @@ > or drag and drop

-

A single PDF file

+

+ A single PDF file +

Your files never leave your device.

@@ -421,7 +423,10 @@
-

+

How It Works

@@ -488,7 +493,10 @@
-

+

Related PDF Tools

@@ -532,7 +540,10 @@
-

+

Frequently Asked Questions

diff --git a/src/pages/svg-to-pdf.html b/src/pages/svg-to-pdf.html index 43a4015..c0ad6e3 100644 --- a/src/pages/svg-to-pdf.html +++ b/src/pages/svg-to-pdf.html @@ -120,10 +120,10 @@ class="text-2xl font-bold text-white mb-2" data-i18n="tools:svgToPdf.name" > - Svg To Pdf Converter Free Online - Convert Files Fast + SVG to PDF

- Convert one or more SVG graphics into a single PDF file. + Create a PDF from one or more SVG images.

@@ -142,7 +142,12 @@ > or drag and drop

-

SVG Graphics

+

+ SVG Graphics +

Your files never leave your device.

@@ -249,7 +254,10 @@
-

+

How It Works

@@ -261,7 +269,7 @@

Upload File

-

+

Click or drag and drop your file to begin

@@ -274,7 +282,9 @@

Process

-

Click the process button to start

+

+ Click the process button to start +

@@ -285,7 +295,9 @@

Download

-

Save your processed file instantly

+

+ Save your processed file instantly +

@@ -293,7 +305,10 @@
-

+

Related PDF Tools

@@ -337,7 +352,10 @@
-

+

Frequently Asked Questions

diff --git a/src/pages/table-of-contents.html b/src/pages/table-of-contents.html index 5b43664..79db66c 100644 --- a/src/pages/table-of-contents.html +++ b/src/pages/table-of-contents.html @@ -129,13 +129,13 @@ class="text-2xl font-bold text-white mb-2" data-i18n="tools:tableOfContents.name" > - Table Of Contents Free Online - Fast & Secure + Table of Contents

- Upload a PDF with bookmarks to generate a table of contents page + Generate a table of contents page from PDF bookmarks.

@@ -154,8 +154,10 @@ > or drag and drop

-

A single PDF file

-

+

+ A single PDF file +

+

Your files never leave your device.

@@ -263,7 +265,10 @@
-

+

How It Works

@@ -275,7 +280,7 @@

Upload File

-

+

Click or drag and drop your file to begin

@@ -288,7 +293,9 @@

Process

-

Click the process button to start

+

+ Click the process button to start +

@@ -299,7 +306,9 @@

Download

-

Save your processed file instantly

+

+ Save your processed file instantly +

@@ -307,7 +316,10 @@
-

+

Related PDF Tools

@@ -351,7 +363,10 @@
-

+

Frequently Asked Questions

diff --git a/src/pages/text-color.html b/src/pages/text-color.html index e70b0b9..3888d81 100644 --- a/src/pages/text-color.html +++ b/src/pages/text-color.html @@ -120,14 +120,13 @@ class="text-2xl font-bold text-white mb-2" data-i18n="tools:changeTextColor.name" > - Text Color Free Online - Fast & Secure + Change Text Color

- Change the color of dark text in your PDF document. Note: This - converts pages to images. + Change the color of text in your PDF.

or drag and drop

-

PDF file

+

+ PDF file +

Your files never leave your device.

@@ -167,12 +168,7 @@ class="block mb-2 text-sm font-medium text-gray-300" >New Text Color - +

Process

-

Click the process button to start

+

+ Click the process button to start +

@@ -263,7 +264,9 @@

Download

-

Save your processed file instantly

+

+ Save your processed file instantly +

@@ -271,7 +274,10 @@
-

+

Related PDF Tools

@@ -315,7 +321,10 @@
-

+

Frequently Asked Questions

diff --git a/src/pages/tiff-to-pdf.html b/src/pages/tiff-to-pdf.html index 841f50f..9c7f724 100644 --- a/src/pages/tiff-to-pdf.html +++ b/src/pages/tiff-to-pdf.html @@ -120,11 +120,10 @@ class="text-2xl font-bold text-white mb-2" data-i18n="tools:tiffToPdf.name" > - Tiff To Pdf Converter Free Online - Convert Files Fast + TIFF to PDF

- Convert one or more single or multi-page TIFF images into a single PDF - file. + Create a PDF from one or more TIFF images.

- Click to select files + Click to select files or drag and drop

-

TIFF images

+

+ TIFF images +

Your files never leave your device.

@@ -220,7 +226,10 @@
-

+

How It Works

@@ -232,7 +241,7 @@

Upload File

-

+

Click or drag and drop your file to begin

@@ -245,7 +254,9 @@

Process

-

Click the process button to start

+

+ Click the process button to start +

@@ -256,7 +267,9 @@

Download

-

Save your processed file instantly

+

+ Save your processed file instantly +

@@ -264,7 +277,10 @@
-

+

Related PDF Tools

@@ -308,7 +324,10 @@
-

+

Frequently Asked Questions

diff --git a/src/pages/txt-to-pdf.html b/src/pages/txt-to-pdf.html index 1354be2..f719e34 100644 --- a/src/pages/txt-to-pdf.html +++ b/src/pages/txt-to-pdf.html @@ -111,18 +111,19 @@ class="flex items-center gap-2 text-indigo-400 hover:text-indigo-300 mb-6 font-semibold" > Back to Tools + > + Back to Tools +

- Txt To Pdf Converter Free Online - Convert Files Fast + Text to PDF

- Upload one or more text files, or type/paste text below to convert to - PDF with custom formatting. + Convert a plain text file into a PDF.

@@ -156,7 +157,9 @@ class="w-10 h-10 mb-3 text-gray-400" >

- Click to select file(s) + Click to select file(s) or drag and drop

Text files (.txt)

@@ -246,12 +249,7 @@ class="block mb-2 text-sm font-medium text-gray-300" >Text Color - +

Process

-

Click the process button to start

+

+ Click the process button to start +

@@ -409,7 +412,9 @@

Download

-

Save your processed file instantly

+

+ Save your processed file instantly +

@@ -417,7 +422,10 @@
-

+

Related PDF Tools

@@ -461,7 +469,10 @@
-

+

Frequently Asked Questions

diff --git a/src/pages/validate-signature-pdf.html b/src/pages/validate-signature-pdf.html index d2af9df..4aea97c 100644 --- a/src/pages/validate-signature-pdf.html +++ b/src/pages/validate-signature-pdf.html @@ -147,7 +147,12 @@ > or drag and drop

-

PDF Documents

+

+ PDF Documents +

Your files never leave your device.

@@ -241,7 +246,10 @@
-

+

How It Works

@@ -294,7 +302,10 @@
-

+

Related PDF Tools

@@ -324,7 +335,10 @@
-

+

Frequently Asked Questions

diff --git a/src/pages/view-metadata.html b/src/pages/view-metadata.html index 266b7c2..42bab6f 100644 --- a/src/pages/view-metadata.html +++ b/src/pages/view-metadata.html @@ -120,11 +120,10 @@ class="text-2xl font-bold text-white mb-2" data-i18n="tools:viewMetadata.name" > - View Metadata Free Online - Fast & Secure + View Metadata

- View all metadata fields from your PDF document including title, - author, creator, and custom fields. + Inspect the hidden properties of your PDF.

or drag and drop

-

A single PDF file

+

+ A single PDF file +

Your files never leave your device.

@@ -219,7 +220,10 @@
-

+

How It Works

@@ -231,7 +235,7 @@

Upload File

-

+

Click or drag and drop your file to begin

@@ -244,7 +248,9 @@

Process

-

Click the process button to start

+

+ Click the process button to start +

@@ -255,7 +261,9 @@

Download

-

Save your processed file instantly

+

+ Save your processed file instantly +

@@ -263,7 +271,10 @@
-

+

Related PDF Tools

@@ -307,7 +318,10 @@
-

+

Frequently Asked Questions

diff --git a/src/pages/vsd-to-pdf.html b/src/pages/vsd-to-pdf.html index 92c72e5..a39bd22 100644 --- a/src/pages/vsd-to-pdf.html +++ b/src/pages/vsd-to-pdf.html @@ -120,7 +120,7 @@ class="text-2xl font-bold text-white mb-2" data-i18n="tools:vsdToPdf.name" > - Vsd To Pdf Converter Free Online - Convert Files Fast + VSD to PDF

Convert Microsoft Visio (VSD, VSDX) files to PDF format. Supports @@ -137,7 +137,9 @@ class="w-10 h-10 mb-3 text-gray-400" >

- Click to select files + Click to select files or drag and drop

-

+

How It Works

@@ -246,7 +251,7 @@

Upload File

-

+

Click or drag and drop your file to begin

@@ -259,7 +264,9 @@

Process

-

Click the process button to start

+

+ Click the process button to start +

@@ -270,7 +277,9 @@

Download

-

Save your processed file instantly

+

+ Save your processed file instantly +

@@ -278,7 +287,10 @@
-

+

Related PDF Tools

@@ -322,7 +334,10 @@
-

+

Frequently Asked Questions

diff --git a/src/pages/wasm-settings.html b/src/pages/wasm-settings.html new file mode 100644 index 0000000..96d88f8 --- /dev/null +++ b/src/pages/wasm-settings.html @@ -0,0 +1,332 @@ + + + + + + + + + Advanced Features Settings - Configure WASM Modules | BentoPDF + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {{> navbar }} + +
+
+ + +

+ Advanced Features Settings +

+

+ Configure external processing modules to enable advanced PDF features. + These modules are optional and licensed separately. +

+ + +
+
+ +
+

+ Pre-configured and ready to use. Advanced + processing modules are loaded automatically from CDN. You can + override the URLs below if you need to use a custom source + (e.g., for air-gapped or self-hosted deployments). +

+
+
+
+ + +
+
+
+
+

PyMuPDF

+

Document Processing Engine

+
+ + Not Configured + +
+

+ Enables: PDF to Text, Markdown, SVG, DOCX, Excel • Extract + Images/Tables • Format Conversion +

+
+ + +
+
+ Recommended: + https://cdn.jsdelivr.net/npm/@bentopdf/pymupdf-wasm@0.11.16/ + +
+
+ + +
+
+
+

Ghostscript

+

PDF/A Conversion Engine

+
+ + Not Configured + +
+

+ Enables: PDF/A-1b, PDF/A-2b, PDF/A-3b Conversion • Font to Outline +

+
+ + +
+
+ Recommended: + https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm/assets/ + +
+
+ + +
+
+
+

CoherentPDF

+

Bookmarks & Metadata Engine

+
+ + Not Configured + +
+

+ Enables: Split by Bookmarks • Edit Bookmarks • PDF Metadata +

+
+ + +
+
+ Recommended: + https://cdn.jsdelivr.net/npm/coherentpdf/dist/ + +
+
+
+ + +
+ + +
+ + +
+

+ License Notice: The external + modules (PyMuPDF, Ghostscript, CoherentPDF) are licensed under + AGPL-3.0 or similar copyleft licenses. By configuring and using + these modules, you agree to their respective license terms. BentoPDF + is compatible with any Ghostscript WASM and PyMuPDF WASM + implementation that follows the expected interface. +

+
+
+
+ + + + + + + + {{> footer }} + + + + + + + + + diff --git a/src/pages/webp-to-pdf.html b/src/pages/webp-to-pdf.html index acb325f..8e8bf30 100644 --- a/src/pages/webp-to-pdf.html +++ b/src/pages/webp-to-pdf.html @@ -120,10 +120,10 @@ class="text-2xl font-bold text-white mb-2" data-i18n="tools:webpToPdf.name" > - Webp To Pdf Converter Free Online - Convert Files Fast + WebP to PDF

- Convert one or more WebP images into a single PDF file. + Create a PDF from one or more WebP images.

@@ -142,7 +142,12 @@ > or drag and drop

-

WebP Images

+

+ WebP Images +

Your files never leave your device.

@@ -249,7 +254,10 @@
-

+

How It Works

@@ -261,7 +269,7 @@

Upload File

-

+

Click or drag and drop your file to begin

@@ -274,7 +282,9 @@

Process

-

Click the process button to start

+

+ Click the process button to start +

@@ -285,7 +295,9 @@

Download

-

Save your processed file instantly

+

+ Save your processed file instantly +

@@ -293,7 +305,10 @@
-

+

Related PDF Tools

@@ -337,7 +352,10 @@
-

+

Frequently Asked Questions

diff --git a/src/pages/word-to-pdf.html b/src/pages/word-to-pdf.html index cf67929..3816b35 100644 --- a/src/pages/word-to-pdf.html +++ b/src/pages/word-to-pdf.html @@ -123,7 +123,7 @@ class="text-2xl font-bold text-white mb-2" data-i18n="tools:wordToPdf.name" > - Word to PDF Converter Free Online - Convert DOCX Fast + Word to PDF

Convert Word documents (DOCX, DOC, ODT, RTF) to PDF format. Supports @@ -243,7 +243,10 @@

-

+

How It Works

@@ -308,7 +311,10 @@
-

+

Related PDF Tools

@@ -354,7 +360,10 @@
-

+

Frequently Asked Questions

diff --git a/src/pages/wpd-to-pdf.html b/src/pages/wpd-to-pdf.html index 0c3bb55..aff42ee 100644 --- a/src/pages/wpd-to-pdf.html +++ b/src/pages/wpd-to-pdf.html @@ -120,7 +120,7 @@ class="text-2xl font-bold text-white mb-2" data-i18n="tools:wpdToPdf.name" > - Wpd To Pdf Converter Free Online - Convert Files Fast + WPD to PDF

Convert WordPerfect documents (WPD) to PDF format. Supports multiple @@ -137,7 +137,9 @@ class="w-10 h-10 mb-3 text-gray-400" >

- Click to select files + Click to select files or drag and drop

-

+

How It Works

@@ -246,7 +251,7 @@

Upload File

-

+

Click or drag and drop your file to begin

@@ -259,7 +264,9 @@

Process

-

Click the process button to start

+

+ Click the process button to start +

@@ -270,7 +277,9 @@

Download

-

Save your processed file instantly

+

+ Save your processed file instantly +

@@ -278,7 +287,10 @@
-

+

Related PDF Tools

@@ -322,7 +334,10 @@
-

+

Frequently Asked Questions

diff --git a/src/pages/wps-to-pdf.html b/src/pages/wps-to-pdf.html index bb8ef4c..ed7a8dc 100644 --- a/src/pages/wps-to-pdf.html +++ b/src/pages/wps-to-pdf.html @@ -120,11 +120,10 @@ class="text-2xl font-bold text-white mb-2" data-i18n="tools:wpsToPdf.name" > - Wps To Pdf Converter Free Online - Convert Files Fast + WPS to PDF

- Convert WPS Office documents (WPS) to PDF format. Supports multiple - files. + Convert WPS Office documents to PDF format. Supports multiple files.

- Click to select files + Click to select files or drag and drop

-

+

How It Works

@@ -246,7 +250,7 @@

Upload File

-

+

Click or drag and drop your file to begin

@@ -259,7 +263,9 @@

Process

-

Click the process button to start

+

+ Click the process button to start +

@@ -270,7 +276,9 @@

Download

-

Save your processed file instantly

+

+ Save your processed file instantly +

@@ -278,7 +286,10 @@
-

+

Related PDF Tools

@@ -322,7 +333,10 @@
-

+

Frequently Asked Questions

diff --git a/src/pages/xml-to-pdf.html b/src/pages/xml-to-pdf.html index a183bdd..6a4831b 100644 --- a/src/pages/xml-to-pdf.html +++ b/src/pages/xml-to-pdf.html @@ -120,7 +120,7 @@ class="text-2xl font-bold text-white mb-2" data-i18n="tools:xmlToPdf.name" > - Xml To Pdf Converter Free Online - Convert Files Fast + XML to PDF

Convert XML documents to PDF format. Supports multiple files. @@ -136,7 +136,9 @@ class="w-10 h-10 mb-3 text-gray-400" >

- Click to select files + Click to select files or drag and drop

-

+

How It Works

@@ -235,7 +240,7 @@

Upload File

-

+

Click or drag and drop your file to begin

@@ -248,7 +253,9 @@

Process

-

Click the process button to start

+

+ Click the process button to start +

@@ -259,7 +266,9 @@

Download

-

Save your processed file instantly

+

+ Save your processed file instantly +

@@ -267,7 +276,10 @@
-

+

Related PDF Tools

@@ -311,7 +323,10 @@
-

+

Frequently Asked Questions

diff --git a/src/pages/xps-to-pdf.html b/src/pages/xps-to-pdf.html index f460487..2161352 100644 --- a/src/pages/xps-to-pdf.html +++ b/src/pages/xps-to-pdf.html @@ -120,7 +120,7 @@ class="text-2xl font-bold text-white mb-2" data-i18n="tools:xpsToPdf.name" > - Xps To Pdf Converter Free Online - Convert Files Fast + XPS to PDF

Convert XPS/OXPS documents to PDF format. Supports multiple files. @@ -229,7 +229,10 @@

-

+

How It Works

@@ -241,7 +244,7 @@

Upload File

-

+

Click or drag and drop your file to begin

@@ -254,7 +257,9 @@

Process

-

Click the process button to start

+

+ Click the process button to start +

@@ -265,7 +270,9 @@

Download

-

Save your processed file instantly

+

+ Save your processed file instantly +

@@ -273,7 +280,10 @@
-

+

Related PDF Tools

@@ -317,7 +327,10 @@
-

+

Frequently Asked Questions

diff --git a/src/partials/footer-simple.html b/src/partials/footer-simple.html index 6be96f9..9dedc19 100644 --- a/src/partials/footer-simple.html +++ b/src/partials/footer-simple.html @@ -4,14 +4,18 @@
- BentoPDF + {{#if brandName}}{{brandName}}{{else}}BentoPDF{{/if}}
-

- © 2026 BentoPDF. All rights reserved. +

Version diff --git a/src/partials/footer.html b/src/partials/footer.html index 81b5a64..44d8d9b 100644 --- a/src/partials/footer.html +++ b/src/partials/footer.html @@ -5,14 +5,15 @@

- BentoPDF + {{#if brandName}}{{brandName}}{{else}}BentoPDF{{/if}}
-

- © 2026 BentoPDF. All rights reserved. +

Version @@ -81,6 +82,11 @@ >Privacy Policy +

  • + Advanced Settings +
  • @@ -129,6 +135,8 @@ @@ -136,6 +144,8 @@ diff --git a/src/partials/navbar-simple.html b/src/partials/navbar-simple.html index 1b1021b..a3d174c 100644 --- a/src/partials/navbar-simple.html +++ b/src/partials/navbar-simple.html @@ -6,12 +6,15 @@ diff --git a/src/partials/navbar.html b/src/partials/navbar.html index 6f97076..ae540cf 100644 --- a/src/partials/navbar.html +++ b/src/partials/navbar.html @@ -7,12 +7,15 @@ id="home-logo" > - - BentoPDF + + {{#if brandName}}{{brandName}}{{else}}BentoPDF{{/if}}
    diff --git a/src/tests/compare/diff-text-runs.test.ts b/src/tests/compare/diff-text-runs.test.ts new file mode 100644 index 0000000..7e51412 --- /dev/null +++ b/src/tests/compare/diff-text-runs.test.ts @@ -0,0 +1,587 @@ +import { describe, expect, it } from 'vitest'; + +import { comparePageModels } from '@/js/compare/engine/compare-page-models.ts'; +import { diffTextRuns } from '@/js/compare/engine/diff-text-runs.ts'; +import { + mergeIntoLines, + sortCompareTextItems, +} from '@/js/compare/engine/extract-page-model.ts'; +import type { + CompareAnnotation, + ComparePageModel, + CompareTextItem, + CompareWordToken, +} from '@/js/compare/types.ts'; + +function makeItem(id: string, text: string): CompareTextItem { + return { + id, + text, + normalizedText: text, + rect: { x: 0, y: 0, width: 10, height: 10 }, + }; +} + +function makePage( + pageNumber: number, + textItems: CompareTextItem[], + overrides: Partial = {} +): ComparePageModel { + return { + pageNumber, + width: 100, + height: 100, + textItems, + plainText: textItems.map((item) => item.normalizedText).join(' '), + hasText: textItems.length > 0, + source: 'pdfjs', + ...overrides, + }; +} + +function makeAnnotation( + subtype: string, + overrides: Partial = {} +): CompareAnnotation { + return { + id: `${subtype}-1`, + subtype, + rect: { x: 0, y: 0, width: 10, height: 10 }, + contents: '', + title: '', + color: '', + ...overrides, + }; +} + +describe('diffTextRuns', () => { + it('detects modified tokens as one change', () => { + const result = diffTextRuns( + [makeItem('a', 'Hello'), makeItem('b', 'world')], + [makeItem('a', 'Hello'), makeItem('c', 'there')] + ); + + expect(result.summary).toEqual({ + added: 0, + removed: 0, + modified: 1, + moved: 0, + styleChanged: 0, + }); + expect(result.changes).toHaveLength(1); + expect(result.changes[0].type).toBe('modified'); + expect(result.changes[0].beforeText).toBe('world'); + expect(result.changes[0].afterText).toBe('there'); + }); + + it('detects added tokens', () => { + const result = diffTextRuns( + [makeItem('a', 'Hello')], + [makeItem('a', 'Hello'), makeItem('b', 'again')] + ); + + expect(result.summary).toEqual({ + added: 1, + removed: 0, + modified: 0, + moved: 0, + styleChanged: 0, + }); + expect(result.changes[0].type).toBe('added'); + }); + + it('splits compound replacements into discrete changes', () => { + const result = diffTextRuns( + [ + makeItem('a', 'This'), + makeItem('b', 'is'), + makeItem('c', 'an'), + makeItem('d', 'example'), + makeItem('e', 'of'), + makeItem('f', 'a'), + makeItem('g', 'data'), + makeItem('h', 'table'), + makeItem('i', 'new.'), + makeItem('j', 'Disabilit'), + ], + [ + makeItem('k', 'Example'), + makeItem('l', 'table'), + makeItem('m', 'This'), + makeItem('n', 'is'), + makeItem('o', 'an'), + makeItem('p', 'example'), + makeItem('q', 'of'), + makeItem('r', 'a'), + makeItem('s', 'data'), + makeItem('t', 'table.'), + makeItem('u', 'Disability'), + ] + ); + + expect(result.changes).toHaveLength(2); + expect(result.summary).toEqual({ + added: 1, + removed: 0, + modified: 1, + moved: 0, + styleChanged: 0, + }); + expect( + result.changes.some( + (change) => + change.type === 'added' && change.afterText === 'Example table' + ) + ).toBe(true); + expect( + result.changes.some( + (change) => + change.type === 'modified' && + change.beforeText === 'table new. Disabilit' && + change.afterText === 'table. Disability' + ) + ).toBe(true); + }); +}); + +describe('comparePageModels', () => { + it('marks pages missing from the second document', () => { + const result = comparePageModels( + makePage(3, [makeItem('a', 'Only')]), + null + ); + + expect(result.status).toBe('left-only'); + expect(result.summary.removed).toBe(1); + expect(result.changes[0].type).toBe('page-removed'); + }); + + it('ignores empty highlight annotations in annotation diff output', () => { + const result = comparePageModels( + makePage(1, [makeItem('a', 'Original')], { + annotations: [makeAnnotation('Highlight')], + }), + makePage(1, [makeItem('b', 'Updated')], { + annotations: [makeAnnotation('Highlight', { id: 'highlight-2' })], + }) + ); + + expect( + result.changes.some( + (change) => + change.category === 'annotation' && + change.description.includes('Highlight annotation') + ) + ).toBe(false); + }); +}); + +describe('sortCompareTextItems', () => { + it('orders tokens by reading order', () => { + const items: CompareTextItem[] = [ + { + ...makeItem('b', 'Body'), + rect: { x: 60, y: 40, width: 10, height: 10 }, + }, + { + ...makeItem('a', 'Title'), + rect: { x: 10, y: 10, width: 10, height: 10 }, + }, + { + ...makeItem('c', 'Next'), + rect: { x: 10, y: 40, width: 10, height: 10 }, + }, + ]; + + expect( + sortCompareTextItems(items).map((item) => item.normalizedText) + ).toEqual(['Title', 'Next', 'Body']); + }); +}); + +describe('mergeIntoLines', () => { + it('merges items on the same Y-line into one item', () => { + const items: CompareTextItem[] = [ + { + id: '0', + text: 'Hello', + normalizedText: 'Hello', + rect: { x: 0, y: 10, width: 50, height: 12 }, + }, + { + id: '1', + text: 'World', + normalizedText: 'World', + rect: { x: 60, y: 10, width: 50, height: 12 }, + }, + ]; + const merged = mergeIntoLines(sortCompareTextItems(items)); + + expect(merged).toHaveLength(1); + expect(merged[0].normalizedText).toBe('Hello World'); + expect(merged[0].rect.x).toBe(0); + expect(merged[0].rect.width).toBe(110); + }); + + it('does not insert spaces inside a split word', () => { + const items: CompareTextItem[] = [ + { + id: '0', + text: 'sam', + normalizedText: 'sam', + rect: { x: 0, y: 10, width: 24, height: 12 }, + }, + { + id: '1', + text: 'e', + normalizedText: 'e', + rect: { x: 24.4, y: 10, width: 8, height: 12 }, + }, + ]; + + const merged = mergeIntoLines(sortCompareTextItems(items)); + + expect(merged).toHaveLength(1); + expect(merged[0].normalizedText).toBe('same'); + }); + + it('keeps items on different Y-lines separate', () => { + const items: CompareTextItem[] = [ + { + id: '0', + text: 'Line 1', + normalizedText: 'Line 1', + rect: { x: 0, y: 10, width: 50, height: 12 }, + }, + { + id: '1', + text: 'Line 2', + normalizedText: 'Line 2', + rect: { x: 0, y: 30, width: 50, height: 12 }, + }, + ]; + const merged = mergeIntoLines(sortCompareTextItems(items)); + + expect(merged).toHaveLength(2); + expect(merged[0].normalizedText).toBe('Line 1'); + expect(merged[1].normalizedText).toBe('Line 2'); + }); + + it('produces same result for different text run boundaries', () => { + const pdf1Items: CompareTextItem[] = [ + { + id: '0', + text: 'Hello World', + normalizedText: 'Hello World', + rect: { x: 0, y: 10, width: 100, height: 12 }, + }, + ]; + const pdf2Items: CompareTextItem[] = [ + { + id: '0', + text: 'Hello', + normalizedText: 'Hello', + rect: { x: 0, y: 10, width: 45, height: 12 }, + }, + { + id: '1', + text: 'World', + normalizedText: 'World', + rect: { x: 55, y: 10, width: 45, height: 12 }, + }, + ]; + + const merged1 = mergeIntoLines(sortCompareTextItems(pdf1Items)); + const merged2 = mergeIntoLines(sortCompareTextItems(pdf2Items)); + + expect(merged1[0].normalizedText).toBe(merged2[0].normalizedText); + + const result = diffTextRuns(merged1, merged2); + expect(result.changes).toHaveLength(0); + }); + + it('detects actual changes after merging', () => { + const pdf1Items: CompareTextItem[] = [ + { + id: '0', + text: 'Sample', + normalizedText: 'Sample', + rect: { x: 0, y: 10, width: 60, height: 14 }, + }, + { + id: '1', + text: 'page text here', + normalizedText: 'page text here', + rect: { x: 0, y: 30, width: 120, height: 14 }, + }, + ]; + const pdf2Items: CompareTextItem[] = [ + { + id: '0', + text: 'Sample', + normalizedText: 'Sample', + rect: { x: 0, y: 10, width: 45, height: 14 }, + }, + { + id: '1', + text: 'PDF', + normalizedText: 'PDF', + rect: { x: 55, y: 10, width: 30, height: 14 }, + }, + { + id: '2', + text: 'pages text here', + normalizedText: 'pages text here', + rect: { x: 0, y: 30, width: 125, height: 14 }, + }, + ]; + + const merged1 = mergeIntoLines(sortCompareTextItems(pdf1Items)); + const merged2 = mergeIntoLines(sortCompareTextItems(pdf2Items)); + + expect(merged1).toHaveLength(2); + expect(merged2).toHaveLength(2); + + const result = diffTextRuns(merged1, merged2); + expect(result.summary.modified).toBe(1); + expect(result.summary.added).toBe(0); + expect(result.summary.removed).toBe(0); + expect(result.changes).toHaveLength(1); + expect(result.changes[0].beforeText).toBe('page'); + expect(result.changes[0].afterText).toBe('PDF pages'); + }); + + it('preserves original casing in change descriptions', () => { + const result = diffTextRuns( + [makeItem('a', 'Sample')], + [makeItem('b', 'Sample PDF')] + ); + + expect(result.changes[0].afterText).toBe('PDF'); + }); + + it('ignores joined versus split words when collapsed text matches', () => { + const result = diffTextRuns( + [makeItem('a', 'non'), makeItem('b', 'tincidunt')], + [makeItem('c', 'nontincidunt')] + ); + + expect(result.changes).toHaveLength(0); + expect(result.summary).toEqual({ + added: 0, + removed: 0, + modified: 0, + moved: 0, + styleChanged: 0, + }); + }); +}); + +function makeItemWithTokens( + id: string, + text: string, + fontName?: string, + fontSize?: number +): CompareTextItem { + const words = text.split(/\s+/).filter(Boolean); + const charWidth = 10 / Math.max(text.length, 1); + let offset = 0; + const wordTokens: CompareWordToken[] = words.map((w) => { + const startIndex = text.indexOf(w, offset); + offset = startIndex + w.length; + return { + word: w, + compareWord: w.toLowerCase(), + rect: { + x: startIndex * charWidth, + y: 0, + width: w.length * charWidth, + height: 10, + }, + fontName, + fontSize, + }; + }); + return { + id, + text, + normalizedText: text, + rect: { x: 0, y: 0, width: 10, height: 10 }, + wordTokens, + }; +} + +describe('detectStyleChanges', () => { + it('detects font name change on identical text', () => { + const result = diffTextRuns( + [makeItemWithTokens('a', 'Hello world test', 'Arial', 12)], + [makeItemWithTokens('b', 'Hello world test', 'Times', 12)] + ); + + expect(result.summary.styleChanged).toBe(1); + expect(result.changes.some((c) => c.type === 'style-changed')).toBe(true); + }); + + it('detects font size change on identical text', () => { + const result = diffTextRuns( + [makeItemWithTokens('a', 'Hello world test', 'Arial', 12)], + [makeItemWithTokens('b', 'Hello world test', 'Arial', 16)] + ); + + expect(result.summary.styleChanged).toBe(1); + const sc = result.changes.find((c) => c.type === 'style-changed')!; + expect(sc.beforeText).toBe('Hello world test'); + }); + + it('ignores negligible font size difference', () => { + const result = diffTextRuns( + [makeItemWithTokens('a', 'Same text here', 'Arial', 12)], + [makeItemWithTokens('b', 'Same text here', 'Arial', 12.3)] + ); + + expect(result.summary.styleChanged).toBe(0); + }); + + it('reports no style change when fonts match', () => { + const result = diffTextRuns( + [makeItemWithTokens('a', 'Identical font', 'Arial', 12)], + [makeItemWithTokens('b', 'Identical font', 'Arial', 12)] + ); + + expect(result.changes).toHaveLength(0); + expect(result.summary.styleChanged).toBe(0); + }); + + it('ignores pdfjs document-scoped font name prefixes', () => { + const result = diffTextRuns( + [makeItemWithTokens('a', 'Same font here', 'g_d0_f3', 12)], + [makeItemWithTokens('b', 'Same font here', 'g_d1_f3', 12)] + ); + + expect(result.changes).toHaveLength(0); + expect(result.summary.styleChanged).toBe(0); + }); +}); + +describe('detectMovedText', () => { + it('detects moved text block with identical words', () => { + const result = diffTextRuns( + [ + makeItem('a', 'Introduction to the topic'), + makeItem('b', 'Another paragraph here'), + ], + [ + makeItem('c', 'Another paragraph here'), + makeItem('d', 'Introduction to the topic'), + ] + ); + + expect(result.summary.moved).toBeGreaterThanOrEqual(1); + expect(result.changes.some((c) => c.type === 'moved')).toBe(true); + expect(result.changes.some((c) => c.type === 'removed')).toBe(false); + expect(result.changes.some((c) => c.type === 'added')).toBe(false); + }); + + it('does not detect move for short text', () => { + const result = diffTextRuns( + [makeItem('a', 'Hi'), makeItem('b', 'World')], + [makeItem('c', 'World'), makeItem('d', 'Hi')] + ); + + expect(result.summary.moved).toBe(0); + }); + + it('does not detect move when text is dissimilar', () => { + const result = diffTextRuns( + [makeItem('a', 'This is the first paragraph with details')], + [makeItem('b', 'Completely different content and wording here')] + ); + + expect(result.summary.moved).toBe(0); + }); +}); + +describe('CJK segmentation in diffTextRuns', () => { + it('segments Chinese text into words', () => { + const result = diffTextRuns( + [makeItem('a', '日本語テストです')], + [makeItem('b', '日本語テストでした')] + ); + + expect(result.changes.length).toBeGreaterThan(0); + expect(result.summary.modified).toBeGreaterThanOrEqual(1); + }); + + it('reports no changes for identical CJK text', () => { + const result = diffTextRuns( + [makeItem('a', '日本語テストです')], + [makeItem('b', '日本語テストです')] + ); + + expect(result.changes).toHaveLength(0); + }); +}); + +describe('content categories', () => { + it('assigns text category to added/removed/modified changes', () => { + const result = diffTextRuns( + [makeItem('a', 'Hello world')], + [makeItem('b', 'Hello there')] + ); + + expect(result.changes).toHaveLength(1); + expect(result.changes[0].category).toBe('text'); + }); + + it('assigns formatting category to style-changed changes', () => { + const result = diffTextRuns( + [makeItemWithTokens('a', 'Hello world test', 'Arial', 12)], + [makeItemWithTokens('b', 'Hello world test', 'Times', 12)] + ); + + const styleChange = result.changes.find((c) => c.type === 'style-changed'); + expect(styleChange).toBeDefined(); + expect(styleChange!.category).toBe('formatting'); + }); + + it('assigns text category to moved changes', () => { + const result = diffTextRuns( + [ + makeItem('a', 'Introduction to the topic'), + makeItem('b', 'Another paragraph here'), + ], + [ + makeItem('c', 'Another paragraph here'), + makeItem('d', 'Introduction to the topic'), + ] + ); + + const movedChange = result.changes.find((c) => c.type === 'moved'); + expect(movedChange).toBeDefined(); + expect(movedChange!.category).toBe('text'); + }); + + it('includes categorySummary on page comparison result', () => { + const result = comparePageModels( + makePage(1, [makeItem('a', 'Hello')]), + makePage(1, [makeItem('b', 'World')]) + ); + + expect(result.categorySummary).toBeDefined(); + const total = Object.values(result.categorySummary).reduce( + (a, b) => a + b, + 0 + ); + expect(total).toBeGreaterThanOrEqual(1); + }); + + it('assigns text category to page-removed changes', () => { + const result = comparePageModels( + makePage(1, [makeItem('a', 'Only')]), + null + ); + + expect(result.changes[0].category).toBe('text'); + expect(result.categorySummary.text).toBe(1); + }); +}); diff --git a/src/tests/compare/ocr-page.test.ts b/src/tests/compare/ocr-page.test.ts new file mode 100644 index 0000000..c98cfe2 --- /dev/null +++ b/src/tests/compare/ocr-page.test.ts @@ -0,0 +1,81 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { createConfiguredTesseractWorker } = vi.hoisted(() => ({ + createConfiguredTesseractWorker: vi.fn(), +})); + +const mockWorker = { + recognize: vi.fn(), + terminate: vi.fn(), +}; + +vi.mock('../../js/utils/tesseract-runtime', () => ({ + createConfiguredTesseractWorker, +})); + +import { recognizePageCanvas } from '../../js/compare/engine/ocr-page'; + +describe('compare OCR page recognition', () => { + beforeEach(() => { + createConfiguredTesseractWorker.mockReset(); + mockWorker.recognize.mockReset(); + mockWorker.terminate.mockReset(); + createConfiguredTesseractWorker.mockResolvedValue(mockWorker); + }); + + it('uses the configured Tesseract worker and maps OCR words into compare text items', async () => { + const progress = vi.fn(); + const canvas = { + width: 300, + height: 150, + } as HTMLCanvasElement; + + mockWorker.recognize.mockResolvedValue({ + data: { + words: [ + { + text: 'Hello', + bbox: { x0: 10, y0: 20, x1: 60, y1: 40 }, + }, + { + text: 'world', + bbox: { x0: 70, y0: 20, x1: 120, y1: 40 }, + }, + ], + }, + }); + + const model = await recognizePageCanvas(canvas, 'eng', progress); + + expect(createConfiguredTesseractWorker).toHaveBeenCalledWith( + 'eng', + 1, + expect.any(Function) + ); + expect(mockWorker.recognize).toHaveBeenCalledWith(canvas); + expect(mockWorker.terminate).toHaveBeenCalledTimes(1); + expect(model.source).toBe('ocr'); + expect(model.hasText).toBe(true); + expect(model.plainText).toContain('Hello'); + expect(model.textItems).toHaveLength(1); + + const logger = createConfiguredTesseractWorker.mock + .calls[0][2] as (message: { status: string; progress: number }) => void; + logger({ status: 'recognizing text', progress: 0.5 }); + expect(progress).toHaveBeenCalledWith('recognizing text', 0.5); + }); + + it('terminates the worker when compare OCR fails', async () => { + const canvas = { + width: 300, + height: 150, + } as HTMLCanvasElement; + mockWorker.recognize.mockRejectedValueOnce(new Error('compare ocr failed')); + + await expect(recognizePageCanvas(canvas, 'eng')).rejects.toThrow( + 'compare ocr failed' + ); + + expect(mockWorker.terminate).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/tests/compare/pair-pages.test.ts b/src/tests/compare/pair-pages.test.ts new file mode 100644 index 0000000..10b83be --- /dev/null +++ b/src/tests/compare/pair-pages.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; + +import { pairPages } from '@/js/compare/engine/pair-pages.ts'; +import type { ComparePageSignature } from '@/js/compare/types.ts'; + +function signature(pageNumber: number, text: string): ComparePageSignature { + return { + pageNumber, + plainText: text, + hasText: text.length > 0, + tokenItems: text + .split(/\s+/) + .filter(Boolean) + .map((token, index) => ({ + id: `${pageNumber}-${index}`, + text: token, + normalizedText: token, + rect: { x: 0, y: 0, width: 0, height: 0 }, + })), + }; +} + +describe('pairPages', () => { + it('pairs reordered and inserted pages without collapsing alignment', () => { + const pairs = pairPages( + [signature(1, 'alpha beta'), signature(2, 'gamma delta')], + [ + signature(1, 'intro page'), + signature(2, 'alpha beta'), + signature(3, 'gamma delta'), + ] + ); + + expect(pairs).toHaveLength(3); + expect(pairs[0]).toMatchObject({ + leftPageNumber: null, + rightPageNumber: 1, + }); + expect(pairs[1]).toMatchObject({ leftPageNumber: 1, rightPageNumber: 2 }); + expect(pairs[2]).toMatchObject({ leftPageNumber: 2, rightPageNumber: 3 }); + }); +}); diff --git a/src/tests/compare/text-normalization.test.ts b/src/tests/compare/text-normalization.test.ts new file mode 100644 index 0000000..6d06e2d --- /dev/null +++ b/src/tests/compare/text-normalization.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; + +import { + isLowQualityExtractedText, + joinNormalizedText, + normalizeCompareText, +} from '@/js/compare/engine/text-normalization.ts'; + +describe('text normalization', () => { + it('joins punctuation without inserting stray spaces', () => { + expect(joinNormalizedText(['Example', 'table', ':', 'v2'])).toBe( + 'Example table: v2' + ); + expect(joinNormalizedText(['"', 'Quoted', 'text', '"'])).toBe( + '"Quoted text"' + ); + }); + + it('normalizes private-use and control characters away', () => { + expect(normalizeCompareText('A\u0000B\uE000C')).toBe('A B C'); + }); + + it('flags punctuation-heavy extraction as low quality', () => { + expect(isLowQualityExtractedText('! " # $ % & \'')).toBe(true); + expect(isLowQualityExtractedText('Example table 2026 revision')).toBe( + false + ); + }); +}); diff --git a/src/tests/font-loader.test.ts b/src/tests/font-loader.test.ts new file mode 100644 index 0000000..dfadcf0 --- /dev/null +++ b/src/tests/font-loader.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; + +import { getFontAssetFileName } from '../js/config/font-mappings'; +import { resolveFontUrl } from '../js/utils/font-loader'; + +describe('font-loader', () => { + it('uses the default public font URL when no offline font base URL is configured', () => { + expect(resolveFontUrl('Noto Sans', {})).toBe( + 'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSans/NotoSans-Regular.ttf' + ); + }); + + it('builds a self-hosted font URL when an OCR font base URL is configured', () => { + expect( + resolveFontUrl('Noto Sans Arabic', { + VITE_OCR_FONT_BASE_URL: 'https://internal.example.com/wasm/ocr/fonts/', + }) + ).toBe( + 'https://internal.example.com/wasm/ocr/fonts/NotoSansArabic-Regular.ttf' + ); + }); + + it('derives the bundled font asset file name from the default font URL', () => { + expect(getFontAssetFileName('Noto Sans SC')).toBe( + 'NotoSansCJKsc-Regular.otf' + ); + }); +}); diff --git a/src/tests/form-creator-extraction.test.ts b/src/tests/form-creator-extraction.test.ts new file mode 100644 index 0000000..d2ff5c8 --- /dev/null +++ b/src/tests/form-creator-extraction.test.ts @@ -0,0 +1,414 @@ +import { describe, expect, it } from 'vitest'; +import { + PDFArray, + PDFDocument, + PDFName, + PDFRadioGroup, + PDFRef, + PDFString, + PDFTextField, + PDFWidgetAnnotation, + rgb, +} from 'pdf-lib'; +import { extractExistingFields } from '../js/logic/form-creator-extraction.ts'; +import type { ExtractedFieldLike } from '@/types'; + +const TEST_EXTRACTION_METRICS = { + pdfViewerOffset: { x: 0, y: 0 }, + pdfViewerScale: 1, +}; + +function extractFieldsForTest(pdfDoc: PDFDocument): ExtractedFieldLike[] { + const result = extractExistingFields({ + pdfDoc, + fieldCounterStart: 0, + metrics: TEST_EXTRACTION_METRICS, + }); + + return result.fields + .filter( + ( + field + ): field is typeof field & { + type: 'text' | 'radio'; + } => field.type === 'text' || field.type === 'radio' + ) + .map((field) => ({ + type: field.type, + name: field.name, + pageIndex: field.pageIndex, + x: field.x, + y: field.y, + width: field.width, + height: field.height, + tooltip: field.tooltip, + required: field.required, + readOnly: field.readOnly, + checked: field.checked, + exportValue: field.exportValue, + groupName: field.groupName, + })); +} + +function getWidgetRef( + widget: PDFWidgetAnnotation, + pdfDoc: PDFDocument +): PDFRef { + const ref = pdfDoc.context.getObjectRef(widget.dict); + if (!ref) { + throw new Error('Expected widget dictionary to be registered in context'); + } + return ref; +} + +function getPageAnnotsArray(pdfDoc: PDFDocument, pageIndex: number): PDFArray { + const page = pdfDoc.getPages()[pageIndex]; + const annots = page.node.get(PDFName.of('Annots')); + const annotsArray = pdfDoc.context.lookupMaybe(annots, PDFArray); + if (!annotsArray) { + throw new Error(`Expected page ${pageIndex} to have an /Annots array`); + } + return annotsArray; +} + +function removeAnnotRefFromPage( + pdfDoc: PDFDocument, + pageIndex: number, + targetRef: PDFRef +): void { + const annotsArray = getPageAnnotsArray(pdfDoc, pageIndex); + const kept = annotsArray.asArray().filter((object) => object !== targetRef); + + const replacement = pdfDoc.context.obj(kept) as PDFArray; + pdfDoc.getPages()[pageIndex].node.set(PDFName.of('Annots'), replacement); +} + +function addAnnotRefToPage( + pdfDoc: PDFDocument, + pageIndex: number, + annotRef: PDFRef +): void { + const annotsArray = getPageAnnotsArray(pdfDoc, pageIndex); + annotsArray.push(annotRef); +} + +async function buildTwoPageDropdownPdf(): Promise<{ + pdfDoc: PDFDocument; +}> { + const pdfDoc = await PDFDocument.create(); + const page1 = pdfDoc.addPage([600, 800]); + const page2 = pdfDoc.addPage([600, 800]); + const form = pdfDoc.getForm(); + + const dropdown = form.createDropdown('statusSelect'); + dropdown.addToPage(page2, { + x: 110, + y: 300, + width: 220, + height: 40, + borderColor: rgb(0, 0, 0), + }); + dropdown.setOptions(['Draft', 'Final']); + dropdown.select('Final'); + dropdown.acroField + .getWidgets()[0] + .dict.set(PDFName.of('TU'), PDFString.of('Status tooltip')); + + const page1Field = form.createTextField('page1CompanionField'); + page1Field.addToPage(page1, { + x: 80, + y: 580, + width: 180, + height: 50, + borderColor: rgb(0, 0, 0), + }); + + return { pdfDoc }; +} + +async function buildTwoPageTextFieldPdf(): Promise<{ + pdfDoc: PDFDocument; + page1Field: PDFTextField; + page2Field: PDFTextField; +}> { + const pdfDoc = await PDFDocument.create(); + const page1 = pdfDoc.addPage([600, 800]); + const page2 = pdfDoc.addPage([600, 800]); + const form = pdfDoc.getForm(); + + const page1Field = form.createTextField('page1TextField'); + page1Field.addToPage(page1, { + x: 80, + y: 580, + width: 320, + height: 80, + borderColor: rgb(0, 0, 0), + }); + page1Field.setText('Page 1'); + page1Field.enableRequired(); + page1Field.acroField + .getWidgets()[0] + .dict.set(PDFName.of('TU'), PDFString.of('First page tooltip')); + + const page2Field = form.createTextField('page2TextField'); + page2Field.addToPage(page2, { + x: 90, + y: 360, + width: 360, + height: 120, + borderColor: rgb(0, 0, 0), + }); + page2Field.setText('Page 2'); + page2Field.enableReadOnly(); + page2Field.acroField + .getWidgets()[0] + .dict.set(PDFName.of('TU'), PDFString.of('Second page tooltip')); + + return { pdfDoc, page1Field, page2Field }; +} + +async function buildTwoPageRadioPdf(): Promise<{ + pdfDoc: PDFDocument; + radioGroup: PDFRadioGroup; +}> { + const pdfDoc = await PDFDocument.create(); + const page1 = pdfDoc.addPage([600, 800]); + const page2 = pdfDoc.addPage([600, 800]); + const form = pdfDoc.getForm(); + + const radioGroup = form.createRadioGroup('statusGroup'); + radioGroup.enableRequired(); + + radioGroup.addOptionToPage('draft', page1, { + x: 120, + y: 620, + width: 20, + height: 20, + borderColor: rgb(0, 0, 0), + }); + + radioGroup.addOptionToPage('final', page2, { + x: 180, + y: 420, + width: 20, + height: 20, + borderColor: rgb(0, 0, 0), + }); + + radioGroup.select('final'); + + const widgets = radioGroup.acroField.getWidgets(); + widgets[0].dict.set(PDFName.of('TU'), PDFString.of('Draft option')); + widgets[1].dict.set(PDFName.of('TU'), PDFString.of('Final option')); + + return { pdfDoc, radioGroup }; +} + +describe('form creator extraction regression', () => { + it('keeps text fields on their original pages when widgets have no /P entry', async () => { + const { pdfDoc, page1Field, page2Field } = await buildTwoPageTextFieldPdf(); + + const page1Widget = page1Field.acroField.getWidgets()[0]; + const page2Widget = page2Field.acroField.getWidgets()[0]; + + page1Widget.dict.delete(PDFName.of('P')); + page2Widget.dict.delete(PDFName.of('P')); + + const extracted = extractFieldsForTest(pdfDoc); + + expect(extracted).toHaveLength(2); + + const first = extracted.find((field) => field.name === 'page1TextField'); + const second = extracted.find((field) => field.name === 'page2TextField'); + + expect(first).toMatchObject({ + type: 'text', + pageIndex: 0, + tooltip: 'First page tooltip', + required: true, + readOnly: false, + }); + + expect(second).toMatchObject({ + type: 'text', + pageIndex: 1, + tooltip: 'Second page tooltip', + required: false, + readOnly: true, + }); + }); + + it('prefers the explicit widget /P page reference when present', async () => { + const { pdfDoc, page1Field } = await buildTwoPageTextFieldPdf(); + + const widget = page1Field.acroField.getWidgets()[0]; + const page2Ref = pdfDoc.getPages()[1].ref; + + widget.setP(page2Ref); + + const extracted = extractFieldsForTest(pdfDoc); + const field = extracted.find((entry) => entry.name === 'page1TextField'); + + expect(field).toBeDefined(); + expect(field?.pageIndex).toBe(1); + }); + + it('extracts radio widgets across different pages when /P is missing', async () => { + const { pdfDoc, radioGroup } = await buildTwoPageRadioPdf(); + + const widgets = radioGroup.acroField.getWidgets(); + widgets.forEach((widget) => { + widget.dict.delete(PDFName.of('P')); + }); + + const extracted = extractFieldsForTest(pdfDoc) + .filter((field) => field.name === 'statusGroup') + .sort((a, b) => a.pageIndex - b.pageIndex); + + expect(extracted).toHaveLength(2); + expect(extracted[0]).toMatchObject({ + type: 'radio', + pageIndex: 0, + exportValue: 'draft', + tooltip: 'Draft option', + groupName: 'statusGroup', + required: true, + }); + expect(extracted[1]).toMatchObject({ + type: 'radio', + pageIndex: 1, + exportValue: 'final', + tooltip: 'Final option', + groupName: 'statusGroup', + required: true, + }); + }); + + it('skips fields whose widgets cannot be resolved to any page', async () => { + const { pdfDoc, page1Field, page2Field } = await buildTwoPageTextFieldPdf(); + + const page2Widget = page2Field.acroField.getWidgets()[0]; + const page2WidgetRef = getWidgetRef(page2Widget, pdfDoc); + + page2Widget.dict.delete(PDFName.of('P')); + removeAnnotRefFromPage(pdfDoc, 1, page2WidgetRef); + + const extracted = extractFieldsForTest(pdfDoc); + + expect(extracted.map((field) => field.name)).toEqual(['page1TextField']); + expect( + extracted.find((field) => field.name === 'page2TextField') + ).toBeUndefined(); + + addAnnotRefToPage(pdfDoc, 1, page2WidgetRef); + const extractedAfterRestore = extractFieldsForTest(pdfDoc); + expect(extractedAfterRestore.map((field) => field.name).sort()).toEqual([ + 'page1TextField', + 'page2TextField', + ]); + }); + + it('matches the same real-world failure mode as the reported sample structure', async () => { + const { pdfDoc, page1Field, page2Field } = await buildTwoPageTextFieldPdf(); + + const page1Widget = page1Field.acroField.getWidgets()[0]; + const page2Widget = page2Field.acroField.getWidgets()[0]; + + page1Widget.dict.delete(PDFName.of('P')); + page2Widget.dict.delete(PDFName.of('P')); + + const page1Annots = getPageAnnotsArray(pdfDoc, 0); + const page2Annots = getPageAnnotsArray(pdfDoc, 1); + + expect(page1Annots.asArray()).toHaveLength(1); + expect(page2Annots.asArray()).toHaveLength(1); + + const extracted = extractFieldsForTest(pdfDoc); + const pageMap = Object.fromEntries( + extracted.map((field) => [field.name, field.pageIndex]) + ); + + expect(pageMap).toEqual({ + page1TextField: 0, + page2TextField: 1, + }); + }); + + it('extracts non-radio field metadata through the shared helper path', async () => { + const { pdfDoc } = await buildTwoPageDropdownPdf(); + const dropdownWidget = pdfDoc + .getForm() + .getDropdown('statusSelect') + .acroField.getWidgets()[0]; + + dropdownWidget.dict.delete(PDFName.of('P')); + + const extracted = extractExistingFields({ + pdfDoc, + fieldCounterStart: 7, + metrics: TEST_EXTRACTION_METRICS, + }); + + const dropdownField = extracted.fields.find( + (field) => field.name === 'statusSelect' + ); + + expect(extracted.nextFieldCounter).toBe(9); + expect(extracted.extractedFieldNames.has('statusSelect')).toBe(true); + expect(dropdownField).toMatchObject({ + id: 'field_8', + type: 'dropdown', + pageIndex: 1, + tooltip: 'Status tooltip', + options: ['Draft', 'Final'], + defaultValue: 'Final', + }); + }); + + it('preserves widget geometry while resolving pages through /Annots fallback', async () => { + const { pdfDoc, page2Field } = await buildTwoPageTextFieldPdf(); + + const widget = page2Field.acroField.getWidgets()[0]; + const rect = widget.getRectangle(); + + widget.dict.delete(PDFName.of('P')); + + const extracted = extractFieldsForTest(pdfDoc); + const field = extracted.find((entry) => entry.name === 'page2TextField'); + + expect(field).toBeDefined(); + expect(field).toMatchObject({ + pageIndex: 1, + x: rect.x, + y: pdfDoc.getPages()[1].getSize().height - rect.y - rect.height, + width: rect.width, + height: rect.height, + }); + }); + + it('does not confuse annotation ownership across pages when multiple widgets exist', async () => { + const { pdfDoc, page1Field, page2Field } = await buildTwoPageTextFieldPdf(); + + const extraPage1 = pdfDoc.getForm().createTextField('page1ExtraField'); + extraPage1.addToPage(pdfDoc.getPages()[0], { + x: 40, + y: 120, + width: 140, + height: 30, + borderColor: rgb(0, 0, 0), + }); + + page1Field.acroField.getWidgets()[0].dict.delete(PDFName.of('P')); + page2Field.acroField.getWidgets()[0].dict.delete(PDFName.of('P')); + extraPage1.acroField.getWidgets()[0].dict.delete(PDFName.of('P')); + + const extracted = extractFieldsForTest(pdfDoc); + const pageMap = new Map( + extracted.map((field) => [field.name, field.pageIndex]) + ); + + expect(pageMap.get('page1TextField')).toBe(0); + expect(pageMap.get('page1ExtraField')).toBe(0); + expect(pageMap.get('page2TextField')).toBe(1); + }); +}); diff --git a/src/tests/hocr-transform.test.ts b/src/tests/hocr-transform.test.ts new file mode 100644 index 0000000..844ce0a --- /dev/null +++ b/src/tests/hocr-transform.test.ts @@ -0,0 +1,440 @@ +import { describe, it, expect } from 'vitest'; +import { + parseBBox, + parseBaseline, + parseTextangle, + getTextDirection, + shouldInjectWordBreaks, + normalizeText, + calculateWordTransform, + calculateSpaceTransform, +} from '../js/utils/hocr-transform'; + +describe('hocr-transform', () => { + describe('parseBBox', () => { + it('should parse valid bbox string', () => { + expect(parseBBox('bbox 100 200 300 400')).toEqual({ + x0: 100, + y0: 200, + x1: 300, + y1: 400, + }); + }); + + it('should parse bbox with other attributes', () => { + expect(parseBBox('bbox 10 20 30 40; x_wconf 95')).toEqual({ + x0: 10, + y0: 20, + x1: 30, + y1: 40, + }); + }); + + it('should return null for missing bbox', () => { + expect(parseBBox('x_wconf 95')).toBeNull(); + }); + + it('should return null for empty string', () => { + expect(parseBBox('')).toBeNull(); + }); + + it('should parse zero values', () => { + expect(parseBBox('bbox 0 0 0 0')).toEqual({ + x0: 0, + y0: 0, + x1: 0, + y1: 0, + }); + }); + + it('should parse large coordinates', () => { + expect(parseBBox('bbox 0 0 2480 3508')).toEqual({ + x0: 0, + y0: 0, + x1: 2480, + y1: 3508, + }); + }); + }); + + describe('parseBaseline', () => { + it('should parse valid baseline', () => { + expect(parseBaseline('baseline 0.012 -5')).toEqual({ + slope: 0.012, + intercept: -5, + }); + }); + + it('should parse zero baseline', () => { + expect(parseBaseline('baseline 0 0')).toEqual({ + slope: 0, + intercept: 0, + }); + }); + + it('should parse negative slope', () => { + expect(parseBaseline('baseline -0.005 3')).toEqual({ + slope: -0.005, + intercept: 3, + }); + }); + + it('should return defaults for missing baseline', () => { + expect(parseBaseline('bbox 100 200 300 400')).toEqual({ + slope: 0, + intercept: 0, + }); + }); + + it('should return defaults for empty string', () => { + expect(parseBaseline('')).toEqual({ + slope: 0, + intercept: 0, + }); + }); + + it('should handle baseline with other attributes', () => { + expect(parseBaseline('bbox 10 20 30 40; baseline 0.1 -2')).toEqual({ + slope: 0.1, + intercept: -2, + }); + }); + }); + + describe('parseTextangle', () => { + it('should parse valid textangle', () => { + expect(parseTextangle('textangle 90')).toBe(90); + }); + + it('should parse float textangle', () => { + expect(parseTextangle('textangle 1.5')).toBe(1.5); + }); + + it('should parse negative textangle', () => { + expect(parseTextangle('textangle -45')).toBe(-45); + }); + + it('should return 0 for missing textangle', () => { + expect(parseTextangle('bbox 0 0 100 100')).toBe(0); + }); + + it('should return 0 for empty string', () => { + expect(parseTextangle('')).toBe(0); + }); + + it('should parse zero textangle', () => { + expect(parseTextangle('textangle 0')).toBe(0); + }); + }); + + describe('getTextDirection', () => { + it('should return rtl for dir="rtl" attribute', () => { + const el = document.createElement('div'); + el.setAttribute('dir', 'rtl'); + expect(getTextDirection(el)).toBe('rtl'); + }); + + it('should return ltr for dir="ltr" attribute', () => { + const el = document.createElement('div'); + el.setAttribute('dir', 'ltr'); + expect(getTextDirection(el)).toBe('ltr'); + }); + + it('should default to ltr when no dir attribute', () => { + const el = document.createElement('div'); + expect(getTextDirection(el)).toBe('ltr'); + }); + + it('should default to ltr for unknown dir values', () => { + const el = document.createElement('div'); + el.setAttribute('dir', 'auto'); + expect(getTextDirection(el)).toBe('ltr'); + }); + }); + + describe('shouldInjectWordBreaks', () => { + it('should return true for English', () => { + const el = document.createElement('div'); + el.setAttribute('lang', 'eng'); + expect(shouldInjectWordBreaks(el)).toBe(true); + }); + + it('should return true for no lang attribute', () => { + const el = document.createElement('div'); + expect(shouldInjectWordBreaks(el)).toBe(true); + }); + + it('should return false for CJK languages', () => { + const cjkLangs = ['chi_sim', 'chi_tra', 'jpn', 'kor', 'zh', 'ja', 'ko']; + cjkLangs.forEach((lang) => { + const el = document.createElement('div'); + el.setAttribute('lang', lang); + expect(shouldInjectWordBreaks(el)).toBe(false); + }); + }); + + it('should return true for non-CJK languages', () => { + const langs = ['fra', 'deu', 'spa', 'ara', 'hin']; + langs.forEach((lang) => { + const el = document.createElement('div'); + el.setAttribute('lang', lang); + expect(shouldInjectWordBreaks(el)).toBe(true); + }); + }); + }); + + describe('normalizeText', () => { + it('should normalize NFKC', () => { + expect(normalizeText('\ufb01')).toBe('fi'); + }); + + it('should keep ASCII text unchanged', () => { + expect(normalizeText('hello')).toBe('hello'); + }); + + it('should handle empty string', () => { + expect(normalizeText('')).toBe(''); + }); + + it('should normalize full-width characters', () => { + expect(normalizeText('\uff21')).toBe('A'); + }); + + it('should normalize superscript digits', () => { + expect(normalizeText('\u00b2')).toBe('2'); + }); + }); + + describe('calculateWordTransform', () => { + const baseLine = { + bbox: { x0: 0, y0: 100, x1: 500, y1: 130 }, + baseline: { slope: 0, intercept: 0 }, + textangle: 0, + words: [], + direction: 'ltr' as const, + injectWordBreaks: true, + }; + + it('should calculate position and font size', () => { + const word = { + text: 'Hello', + bbox: { x0: 10, y0: 100, x1: 60, y1: 120 }, + confidence: 95, + }; + const fontWidthFn = (_text: string, fontSize: number) => fontSize * 2.5; + + const result = calculateWordTransform(word, baseLine, 800, fontWidthFn); + + expect(result.x).toBe(10); + expect(result.y).toBe(680); + expect(result.fontSize).toBeGreaterThan(0); + expect(result.horizontalScale).toBeGreaterThan(0); + }); + + it('should clamp font size to max 2x word height', () => { + const word = { + text: 'x', + bbox: { x0: 0, y0: 0, x1: 1000, y1: 10 }, + confidence: 95, + }; + const fontWidthFn = (_text: string, _fontSize: number) => 0.001; + + const result = calculateWordTransform(word, baseLine, 800, fontWidthFn); + expect(result.fontSize).toBeLessThanOrEqual(20); + }); + + it('should clamp font size to minimum 1', () => { + const word = { + text: 'x', + bbox: { x0: 0, y0: 0, x1: 1, y1: 10 }, + confidence: 95, + }; + const fontWidthFn = (_text: string, _fontSize: number) => 10000; + + const result = calculateWordTransform(word, baseLine, 800, fontWidthFn); + expect(result.fontSize).toBeGreaterThanOrEqual(1); + }); + + it('should handle zero font width gracefully', () => { + const word = { + text: '', + bbox: { x0: 0, y0: 0, x1: 50, y1: 20 }, + confidence: 95, + }; + const fontWidthFn = () => 0; + + const result = calculateWordTransform(word, baseLine, 800, fontWidthFn); + expect(result.horizontalScale).toBe(1); + }); + + it('should incorporate baseline slope into rotation', () => { + const slopedLine = { + ...baseLine, + baseline: { slope: 0.1, intercept: 0 }, + }; + const word = { + text: 'Hi', + bbox: { x0: 10, y0: 100, x1: 40, y1: 115 }, + confidence: 90, + }; + const fontWidthFn = (_text: string, fontSize: number) => fontSize * 1.5; + + const result = calculateWordTransform(word, slopedLine, 800, fontWidthFn); + expect(result.rotation).not.toBe(0); + }); + + it('should incorporate textangle into rotation', () => { + const angledLine = { ...baseLine, textangle: 5 }; + const word = { + text: 'Hi', + bbox: { x0: 10, y0: 100, x1: 40, y1: 115 }, + confidence: 90, + }; + const fontWidthFn = (_text: string, fontSize: number) => fontSize * 1.5; + + const result = calculateWordTransform(word, angledLine, 800, fontWidthFn); + expect(result.rotation).toBe(-5); + }); + }); + + describe('calculateSpaceTransform', () => { + const baseLine = { + bbox: { x0: 0, y0: 100, x1: 500, y1: 130 }, + baseline: { slope: 0, intercept: 0 }, + textangle: 0, + words: [], + direction: 'ltr' as const, + injectWordBreaks: true, + }; + + it('should calculate space between two words', () => { + const prev = { + text: 'Hello', + bbox: { x0: 10, y0: 100, x1: 60, y1: 120 }, + confidence: 95, + }; + const next = { + text: 'World', + bbox: { x0: 70, y0: 100, x1: 130, y1: 120 }, + confidence: 95, + }; + const spaceWidthFn = (fontSize: number) => fontSize * 0.3; + + const result = calculateSpaceTransform( + prev, + next, + baseLine, + 800, + spaceWidthFn + ); + expect(result).not.toBeNull(); + expect(result!.x).toBe(60); + expect(result!.horizontalScale).toBeGreaterThan(0); + }); + + it('should return null when gap is zero or negative', () => { + const prev = { + text: 'Hello', + bbox: { x0: 10, y0: 100, x1: 60, y1: 120 }, + confidence: 95, + }; + const next = { + text: 'World', + bbox: { x0: 60, y0: 100, x1: 120, y1: 120 }, + confidence: 95, + }; + const spaceWidthFn = (fontSize: number) => fontSize * 0.3; + + expect( + calculateSpaceTransform(prev, next, baseLine, 800, spaceWidthFn) + ).toBeNull(); + }); + + it('should return null when overlapping words', () => { + const prev = { + text: 'Hello', + bbox: { x0: 10, y0: 100, x1: 80, y1: 120 }, + confidence: 95, + }; + const next = { + text: 'World', + bbox: { x0: 70, y0: 100, x1: 130, y1: 120 }, + confidence: 95, + }; + const spaceWidthFn = (fontSize: number) => fontSize * 0.3; + + expect( + calculateSpaceTransform(prev, next, baseLine, 800, spaceWidthFn) + ).toBeNull(); + }); + + it('should return null when space width function returns 0', () => { + const prev = { + text: 'Hello', + bbox: { x0: 10, y0: 100, x1: 60, y1: 120 }, + confidence: 95, + }; + const next = { + text: 'World', + bbox: { x0: 70, y0: 100, x1: 130, y1: 120 }, + confidence: 95, + }; + + expect( + calculateSpaceTransform(prev, next, baseLine, 800, () => 0) + ).toBeNull(); + }); + + it('should account for baseline intercept in y position', () => { + const lineWithIntercept = { + ...baseLine, + baseline: { slope: 0, intercept: -5 }, + }; + const prev = { + text: 'A', + bbox: { x0: 0, y0: 100, x1: 20, y1: 130 }, + confidence: 90, + }; + const next = { + text: 'B', + bbox: { x0: 30, y0: 100, x1: 50, y1: 130 }, + confidence: 90, + }; + const spaceWidthFn = (fontSize: number) => fontSize * 0.3; + + const result = calculateSpaceTransform( + prev, + next, + lineWithIntercept, + 800, + spaceWidthFn + ); + expect(result).not.toBeNull(); + expect(result!.y).toBe(800 - 130 - -5); + }); + + it('should use line height + intercept for font size', () => { + const prev = { + text: 'A', + bbox: { x0: 0, y0: 100, x1: 20, y1: 130 }, + confidence: 90, + }; + const next = { + text: 'B', + bbox: { x0: 30, y0: 100, x1: 50, y1: 130 }, + confidence: 90, + }; + const spaceWidthFn = (fontSize: number) => fontSize * 0.3; + + const result = calculateSpaceTransform( + prev, + next, + baseLine, + 800, + spaceWidthFn + ); + expect(result).not.toBeNull(); + expect(result!.fontSize).toBe(30); + }); + }); +}); diff --git a/src/tests/i18n.test.ts b/src/tests/i18n.test.ts new file mode 100644 index 0000000..2935bb3 --- /dev/null +++ b/src/tests/i18n.test.ts @@ -0,0 +1,129 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { getLanguageFromUrl } from '@/js/i18n/i18n'; + +describe('getLanguageFromUrl', () => { + const originalLocation = window.location; + const originalNavigator = window.navigator; + + beforeEach(() => { + Object.defineProperty(window, 'location', { + value: { ...originalLocation, pathname: '/' }, + writable: true, + configurable: true, + }); + + localStorage.clear(); + + // Reset navigator + Object.defineProperty(window, 'navigator', { + value: { ...originalNavigator }, + writable: true, + configurable: true, + }); + Object.defineProperty(window.navigator, 'languages', { + value: [], + configurable: true, + }); + + // Reset import.meta.env + vi.stubEnv('BASE_URL', '/'); + vi.stubEnv('VITE_DEFAULT_LANGUAGE', 'en'); + }); + + afterEach(() => { + Object.defineProperty(window, 'location', { + value: originalLocation, + writable: true, + configurable: true, + }); + Object.defineProperty(window, 'navigator', { + value: originalNavigator, + writable: true, + configurable: true, + }); + vi.unstubAllEnvs(); + }); + + it('should return language from URL path', () => { + window.location.pathname = '/de/about'; + expect(getLanguageFromUrl()).toBe('de'); + }); + + it('should prioritize URL path over localStorage', () => { + window.location.pathname = '/fr/'; + localStorage.setItem('i18nextLng', 'es'); + expect(getLanguageFromUrl()).toBe('fr'); + }); + + it('should return language from localStorage if URL has no language', () => { + window.location.pathname = '/about'; + localStorage.setItem('i18nextLng', 'it'); + expect(getLanguageFromUrl()).toBe('it'); + }); + + it('should return exact match from navigator.languages', () => { + window.location.pathname = '/'; + Object.defineProperty(window.navigator, 'languages', { + value: ['zh-TW', 'en-US', 'en'], + configurable: true, + }); + expect(getLanguageFromUrl()).toBe('zh-TW'); + }); + + it('should return primary language match from navigator.languages', () => { + window.location.pathname = '/'; + // 'de-AT' is not in supportedLanguages, but we should match its primary 'de' + Object.defineProperty(window.navigator, 'languages', { + value: ['de-AT', 'en-US', 'en'], + configurable: true, + }); + expect(getLanguageFromUrl()).toBe('de'); + }); + + it('should return first matched language from navigator.languages', () => { + window.location.pathname = '/'; + Object.defineProperty(window.navigator, 'languages', { + value: ['fr-CA', 'de-DE', 'en'], + configurable: true, + }); + expect(getLanguageFromUrl()).toBe('fr'); + }); + + it('should ignore unsupported languages in navigator.languages', () => { + window.location.pathname = '/'; + Object.defineProperty(window.navigator, 'languages', { + value: ['xx-XX', 'es-ES'], + configurable: true, + }); + expect(getLanguageFromUrl()).toBe('es'); + }); + + it('should fallback to env variable if no earlier match', () => { + window.location.pathname = '/'; + Object.defineProperty(window.navigator, 'languages', { + value: ['xx'], + configurable: true, + }); // unsupported + vi.stubEnv('VITE_DEFAULT_LANGUAGE', 'vi'); + expect(getLanguageFromUrl()).toBe('vi'); + }); + + it('should fallback to en if everything else fails', () => { + window.location.pathname = '/'; + Object.defineProperty(window.navigator, 'languages', { + value: [], + configurable: true, + }); + vi.stubEnv('VITE_DEFAULT_LANGUAGE', ''); + expect(getLanguageFromUrl()).toBe('en'); + }); + + it('should handle missing navigator object gracefully', () => { + window.location.pathname = '/'; + Object.defineProperty(window, 'navigator', { + value: undefined, + writable: true, + }); + expect(getLanguageFromUrl()).toBe('en'); + }); +}); diff --git a/src/tests/image-effects.test.ts b/src/tests/image-effects.test.ts new file mode 100644 index 0000000..5850335 --- /dev/null +++ b/src/tests/image-effects.test.ts @@ -0,0 +1,277 @@ +import { describe, it, expect } from 'vitest'; +import { + rgbToHsl, + hslToRgb, + applyGreyscale, + applyInvertColors, +} from '../js/utils/image-effects'; + +function createImageData(pixels: number[][]): ImageData { + const data = new Uint8ClampedArray(pixels.flat()); + return { + data, + width: pixels.length, + height: 1, + colorSpace: 'srgb', + } as ImageData; +} + +describe('image-effects', () => { + describe('rgbToHsl', () => { + it('should convert pure red', () => { + const [h, s, l] = rgbToHsl(255, 0, 0); + expect(h).toBeCloseTo(0, 2); + expect(s).toBeCloseTo(1, 2); + expect(l).toBeCloseTo(0.5, 2); + }); + + it('should convert pure green', () => { + const [h, s, l] = rgbToHsl(0, 255, 0); + expect(h).toBeCloseTo(1 / 3, 2); + expect(s).toBeCloseTo(1, 2); + expect(l).toBeCloseTo(0.5, 2); + }); + + it('should convert pure blue', () => { + const [h, s, l] = rgbToHsl(0, 0, 255); + expect(h).toBeCloseTo(2 / 3, 2); + expect(s).toBeCloseTo(1, 2); + expect(l).toBeCloseTo(0.5, 2); + }); + + it('should convert white', () => { + const [h, s, l] = rgbToHsl(255, 255, 255); + expect(h).toBe(0); + expect(s).toBe(0); + expect(l).toBeCloseTo(1, 2); + }); + + it('should convert black', () => { + const [h, s, l] = rgbToHsl(0, 0, 0); + expect(h).toBe(0); + expect(s).toBe(0); + expect(l).toBe(0); + }); + + it('should convert mid gray', () => { + const [h, s, l] = rgbToHsl(128, 128, 128); + expect(h).toBe(0); + expect(s).toBe(0); + expect(l).toBeCloseTo(0.502, 2); + }); + + it('should convert yellow', () => { + const [h, s, l] = rgbToHsl(255, 255, 0); + expect(h).toBeCloseTo(1 / 6, 2); + expect(s).toBeCloseTo(1, 2); + expect(l).toBeCloseTo(0.5, 2); + }); + + it('should convert cyan', () => { + const [h, s, l] = rgbToHsl(0, 255, 255); + expect(h).toBeCloseTo(0.5, 2); + expect(s).toBeCloseTo(1, 2); + expect(l).toBeCloseTo(0.5, 2); + }); + + it('should convert magenta', () => { + const [h, s, l] = rgbToHsl(255, 0, 255); + expect(h).toBeCloseTo(5 / 6, 2); + expect(s).toBeCloseTo(1, 2); + expect(l).toBeCloseTo(0.5, 2); + }); + + it('should handle dark colors (l < 0.5)', () => { + const [h, s, l] = rgbToHsl(128, 0, 0); + expect(h).toBeCloseTo(0, 2); + expect(s).toBeCloseTo(1, 2); + expect(l).toBeCloseTo(0.251, 2); + }); + + it('should handle light colors (l > 0.5)', () => { + const [h, s, l] = rgbToHsl(255, 128, 128); + expect(l).toBeGreaterThan(0.5); + expect(s).toBeGreaterThan(0); + }); + }); + + describe('hslToRgb', () => { + it('should convert pure red', () => { + expect(hslToRgb(0, 1, 0.5)).toEqual([255, 0, 0]); + }); + + it('should convert pure green', () => { + expect(hslToRgb(1 / 3, 1, 0.5)).toEqual([0, 255, 0]); + }); + + it('should convert pure blue', () => { + expect(hslToRgb(2 / 3, 1, 0.5)).toEqual([0, 0, 255]); + }); + + it('should convert white', () => { + expect(hslToRgb(0, 0, 1)).toEqual([255, 255, 255]); + }); + + it('should convert black', () => { + expect(hslToRgb(0, 0, 0)).toEqual([0, 0, 0]); + }); + + it('should convert gray (zero saturation)', () => { + const [r, g, b] = hslToRgb(0, 0, 0.5); + expect(r).toBe(g); + expect(g).toBe(b); + expect(r).toBe(128); + }); + + it('should convert yellow', () => { + expect(hslToRgb(1 / 6, 1, 0.5)).toEqual([255, 255, 0]); + }); + + it('should convert cyan', () => { + expect(hslToRgb(0.5, 1, 0.5)).toEqual([0, 255, 255]); + }); + + it('should handle different saturation values', () => { + const [r1] = hslToRgb(0, 0.5, 0.5); + const [r2] = hslToRgb(0, 1, 0.5); + expect(r2).toBeGreaterThan(r1); + }); + + it('should handle l < 0.5 branch', () => { + const result = hslToRgb(0, 1, 0.25); + expect(result[0]).toBe(128); + expect(result[1]).toBe(0); + expect(result[2]).toBe(0); + }); + + it('should handle l >= 0.5 branch', () => { + const result = hslToRgb(0, 1, 0.75); + expect(result[0]).toBe(255); + expect(result[1]).toBe(128); + expect(result[2]).toBe(128); + }); + }); + + describe('rgbToHsl <-> hslToRgb round-trip', () => { + const testColors: [number, number, number][] = [ + [255, 0, 0], + [0, 255, 0], + [0, 0, 255], + [255, 255, 0], + [0, 255, 255], + [255, 0, 255], + [128, 128, 128], + [200, 100, 50], + [50, 150, 200], + [10, 10, 10], + [245, 245, 245], + ]; + + testColors.forEach(([r, g, b]) => { + it(`should round-trip rgb(${r}, ${g}, ${b})`, () => { + const [h, s, l] = rgbToHsl(r, g, b); + const [r2, g2, b2] = hslToRgb(h, s, l); + expect(r2).toBeCloseTo(r, 0); + expect(g2).toBeCloseTo(g, 0); + expect(b2).toBeCloseTo(b, 0); + }); + }); + }); + + describe('applyGreyscale', () => { + it('should convert colored pixel to grey using luminance weights', () => { + const imageData = createImageData([[255, 0, 0, 255]]); + applyGreyscale(imageData); + const expected = Math.round(0.299 * 255); + expect(imageData.data[0]).toBe(expected); + expect(imageData.data[1]).toBe(expected); + expect(imageData.data[2]).toBe(expected); + expect(imageData.data[3]).toBe(255); + }); + + it('should keep white as white', () => { + const imageData = createImageData([[255, 255, 255, 255]]); + applyGreyscale(imageData); + expect(imageData.data[0]).toBe(255); + expect(imageData.data[1]).toBe(255); + expect(imageData.data[2]).toBe(255); + }); + + it('should keep black as black', () => { + const imageData = createImageData([[0, 0, 0, 255]]); + applyGreyscale(imageData); + expect(imageData.data[0]).toBe(0); + expect(imageData.data[1]).toBe(0); + expect(imageData.data[2]).toBe(0); + }); + + it('should process multiple pixels', () => { + const imageData = createImageData([ + [255, 0, 0, 255], + [0, 255, 0, 255], + [0, 0, 255, 255], + ]); + applyGreyscale(imageData); + expect(imageData.data[0]).toBe(imageData.data[1]); + expect(imageData.data[4]).toBe(imageData.data[5]); + expect(imageData.data[8]).toBe(imageData.data[9]); + }); + + it('should not modify alpha channel', () => { + const imageData = createImageData([[100, 150, 200, 128]]); + applyGreyscale(imageData); + expect(imageData.data[3]).toBe(128); + }); + }); + + describe('applyInvertColors', () => { + it('should invert black to white', () => { + const imageData = createImageData([[0, 0, 0, 255]]); + applyInvertColors(imageData); + expect(imageData.data[0]).toBe(255); + expect(imageData.data[1]).toBe(255); + expect(imageData.data[2]).toBe(255); + }); + + it('should invert white to black', () => { + const imageData = createImageData([[255, 255, 255, 255]]); + applyInvertColors(imageData); + expect(imageData.data[0]).toBe(0); + expect(imageData.data[1]).toBe(0); + expect(imageData.data[2]).toBe(0); + }); + + it('should invert mid-range colors', () => { + const imageData = createImageData([[100, 150, 200, 255]]); + applyInvertColors(imageData); + expect(imageData.data[0]).toBe(155); + expect(imageData.data[1]).toBe(105); + expect(imageData.data[2]).toBe(55); + }); + + it('should not modify alpha channel', () => { + const imageData = createImageData([[100, 150, 200, 128]]); + applyInvertColors(imageData); + expect(imageData.data[3]).toBe(128); + }); + + it('should be its own inverse (double invert = original)', () => { + const imageData = createImageData([[42, 128, 200, 255]]); + applyInvertColors(imageData); + applyInvertColors(imageData); + expect(imageData.data[0]).toBe(42); + expect(imageData.data[1]).toBe(128); + expect(imageData.data[2]).toBe(200); + }); + + it('should process multiple pixels', () => { + const imageData = createImageData([ + [0, 0, 0, 255], + [255, 255, 255, 255], + ]); + applyInvertColors(imageData); + expect(imageData.data[0]).toBe(255); + expect(imageData.data[4]).toBe(0); + }); + }); +}); diff --git a/src/tests/ocr.test.ts b/src/tests/ocr.test.ts new file mode 100644 index 0000000..97e175b --- /dev/null +++ b/src/tests/ocr.test.ts @@ -0,0 +1,185 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { + createConfiguredTesseractWorker, + getPDFDocument, + getFontForLanguage, + parseHocrDocument, +} = vi.hoisted(() => ({ + createConfiguredTesseractWorker: vi.fn(), + getPDFDocument: vi.fn(), + getFontForLanguage: vi.fn(), + parseHocrDocument: vi.fn(), +})); + +const mockWorker = { + setParameters: vi.fn(), + recognize: vi.fn(), + terminate: vi.fn(), +}; + +const mockPdfPage = { + getViewport: vi.fn(() => ({ width: 200, height: 100 })), + render: vi.fn(() => ({ promise: Promise.resolve() })), +}; + +const mockPdfOutputPage = { + drawImage: vi.fn(), + drawText: vi.fn(), +}; + +const mockPdfDoc = { + registerFontkit: vi.fn(), + embedFont: vi.fn(async () => ({ widthOfTextAtSize: vi.fn(() => 12) })), + addPage: vi.fn(() => mockPdfOutputPage), + embedPng: vi.fn(async () => ({ id: 'png' })), + save: vi.fn(async () => new Uint8Array([1, 2, 3])), +}; + +vi.mock('../js/utils/tesseract-runtime', () => ({ + createConfiguredTesseractWorker, +})); + +vi.mock('../js/utils/helpers.js', () => ({ + getPDFDocument, +})); + +vi.mock('../js/utils/font-loader.js', () => ({ + getFontForLanguage, +})); + +vi.mock('../js/utils/hocr-transform.js', () => ({ + parseHocrDocument, + calculateWordTransform: vi.fn(), + calculateSpaceTransform: vi.fn(), +})); + +vi.mock('pdf-lib', () => ({ + PDFDocument: { + create: vi.fn(async () => mockPdfDoc), + }, + StandardFonts: { + Helvetica: 'Helvetica', + }, + rgb: vi.fn(() => ({ r: 0, g: 0, b: 0 })), +})); + +vi.mock('@pdf-lib/fontkit', () => ({ + default: {}, +})); + +import { performOcr } from '../js/utils/ocr'; + +describe('performOcr', () => { + const originalCreateElement = document.createElement.bind(document); + const originalFileReader = globalThis.FileReader; + + beforeEach(() => { + createConfiguredTesseractWorker.mockReset(); + getPDFDocument.mockReset(); + getFontForLanguage.mockReset(); + parseHocrDocument.mockReset(); + + mockWorker.setParameters.mockReset(); + mockWorker.recognize.mockReset(); + mockWorker.terminate.mockReset(); + mockPdfPage.getViewport.mockClear(); + mockPdfPage.render.mockClear(); + mockPdfOutputPage.drawImage.mockClear(); + mockPdfOutputPage.drawText.mockClear(); + mockPdfDoc.registerFontkit.mockClear(); + mockPdfDoc.embedFont.mockClear(); + mockPdfDoc.addPage.mockClear(); + mockPdfDoc.embedPng.mockClear(); + mockPdfDoc.save.mockClear(); + + createConfiguredTesseractWorker.mockResolvedValue(mockWorker); + getPDFDocument.mockReturnValue({ + promise: Promise.resolve({ + numPages: 1, + getPage: vi.fn(async () => mockPdfPage), + }), + }); + getFontForLanguage.mockResolvedValue(new Uint8Array([1, 2, 3])); + mockWorker.recognize.mockResolvedValue({ + data: { + text: 'Recognized text', + hocr: '', + }, + }); + + document.createElement = ((tagName: string) => { + if (tagName !== 'canvas') { + return originalCreateElement(tagName); + } + + return { + width: 0, + height: 0, + getContext: vi.fn(() => ({ + canvas: { width: 200, height: 100 }, + getImageData: vi.fn(() => ({ data: new Uint8ClampedArray(4) })), + putImageData: vi.fn(), + })), + toBlob: vi.fn((callback: (blob: Blob) => void) => { + callback( + new Blob([new Uint8Array([1, 2, 3])], { type: 'image/png' }) + ); + }), + } as unknown as HTMLCanvasElement; + }) as typeof document.createElement; + + globalThis.FileReader = class { + result: ArrayBuffer = new Uint8Array([1, 2, 3]).buffer; + onload: null | (() => void) = null; + onerror: null | (() => void) = null; + + readAsArrayBuffer() { + this.onload?.(); + } + } as unknown as typeof FileReader; + }); + + afterEach(() => { + document.createElement = originalCreateElement; + globalThis.FileReader = originalFileReader; + }); + + it('uses the configured Tesseract worker and terminates it after OCR completes', async () => { + const result = await performOcr(new Uint8Array([1, 2, 3]), { + language: 'eng', + resolution: 2, + binarize: false, + whitelist: '', + }); + + expect(createConfiguredTesseractWorker).toHaveBeenCalledWith( + 'eng', + 1, + expect.any(Function) + ); + expect(mockWorker.setParameters).toHaveBeenCalledWith({ + tessjs_create_hocr: '1', + tessedit_pageseg_mode: '3', + }); + expect(mockWorker.recognize).toHaveBeenCalledTimes(1); + expect(mockWorker.terminate).toHaveBeenCalledTimes(1); + expect(result.fullText).toContain('Recognized text'); + expect(result.pdfBytes).toBeInstanceOf(Uint8Array); + }); + + it('terminates the Tesseract worker when OCR fails', async () => { + mockWorker.recognize.mockRejectedValueOnce(new Error('ocr failed')); + + await expect( + performOcr(new Uint8Array([1, 2, 3]), { + language: 'eng', + resolution: 2, + binarize: false, + whitelist: '', + }) + ).rejects.toThrow('ocr failed'); + + expect(mockWorker.terminate).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/tests/pdf-operations.test.ts b/src/tests/pdf-operations.test.ts new file mode 100644 index 0000000..d3d4ba5 --- /dev/null +++ b/src/tests/pdf-operations.test.ts @@ -0,0 +1,272 @@ +import { describe, it, expect } from 'vitest'; +import { + parsePageRange, + parseDeletePages, + mergePdfs, + splitPdf, + deletePdfPages, + rotatePdfUniform, + rotatePdfPages, +} from '../js/utils/pdf-operations'; +import { PDFDocument } from 'pdf-lib'; + +async function createTestPdf(pageCount: number): Promise { + const doc = await PDFDocument.create(); + for (let i = 0; i < pageCount; i++) { + doc.addPage([612, 792]); + } + return new Uint8Array(await doc.save()); +} + +describe('pdf-operations', () => { + describe('parsePageRange', () => { + it('should parse single page', () => { + expect(parsePageRange('3', 10)).toEqual([2]); + }); + + it('should parse multiple pages', () => { + expect(parsePageRange('1,3,5', 10)).toEqual([0, 2, 4]); + }); + + it('should parse range', () => { + expect(parsePageRange('2-5', 10)).toEqual([1, 2, 3, 4]); + }); + + it('should parse mixed pages and ranges', () => { + expect(parsePageRange('1,3-5,8', 10)).toEqual([0, 2, 3, 4, 7]); + }); + + it('should handle spaces', () => { + expect(parsePageRange(' 1 , 3 - 5 ', 10)).toEqual([0, 2, 3, 4]); + }); + + it('should deduplicate and sort', () => { + expect(parsePageRange('5,3,1,3-5', 10)).toEqual([0, 2, 3, 4]); + }); + + it('should clamp start to 1', () => { + expect(parsePageRange('0-3', 10)).toEqual([0, 1, 2]); + }); + + it('should clamp end to totalPages', () => { + expect(parsePageRange('8-15', 10)).toEqual([7, 8, 9]); + }); + + it('should ignore pages outside bounds', () => { + expect(parsePageRange('0,11,5', 10)).toEqual([4]); + }); + + it('should handle single page document', () => { + expect(parsePageRange('1', 1)).toEqual([0]); + }); + + it('should handle invalid non-numeric input', () => { + expect(parsePageRange('abc', 10)).toEqual([]); + }); + + it('should handle empty parts', () => { + expect(parsePageRange('1,,3', 10)).toEqual([0, 2]); + }); + + it('should handle range with invalid end (NaN defaults to totalPages)', () => { + expect(parsePageRange('3-abc', 10)).toEqual([2, 3, 4, 5, 6, 7, 8, 9]); + }); + }); + + describe('parseDeletePages', () => { + it('should parse single page', () => { + expect(parseDeletePages('3', 10)).toEqual(new Set([3])); + }); + + it('should parse multiple pages', () => { + expect(parseDeletePages('1,3,5', 10)).toEqual(new Set([1, 3, 5])); + }); + + it('should parse range', () => { + expect(parseDeletePages('2-5', 10)).toEqual(new Set([2, 3, 4, 5])); + }); + + it('should parse mixed pages and ranges', () => { + expect(parseDeletePages('1,3-5,8', 10)).toEqual(new Set([1, 3, 4, 5, 8])); + }); + + it('should ignore out-of-bounds pages', () => { + expect(parseDeletePages('0,11,5', 10)).toEqual(new Set([5])); + }); + + it('should handle spaces', () => { + expect(parseDeletePages(' 1 , 3 ', 10)).toEqual(new Set([1, 3])); + }); + + it('should clamp range to valid bounds', () => { + expect(parseDeletePages('0-3', 10)).toEqual(new Set([1, 2, 3])); + }); + + it('should clamp range end to totalPages', () => { + expect(parseDeletePages('8-15', 10)).toEqual(new Set([8, 9, 10])); + }); + + it('should return 1-indexed page numbers', () => { + const result = parseDeletePages('1', 10); + expect(result.has(1)).toBe(true); + expect(result.has(0)).toBe(false); + }); + + it('should handle empty parts gracefully', () => { + expect(parseDeletePages('1,,5', 10)).toEqual(new Set([1, 5])); + }); + }); + + describe('mergePdfs', () => { + it('should merge two single-page PDFs', async () => { + const pdf1 = await createTestPdf(1); + const pdf2 = await createTestPdf(1); + const merged = await mergePdfs([pdf1, pdf2]); + const doc = await PDFDocument.load(merged); + expect(doc.getPageCount()).toBe(2); + }); + + it('should merge multiple PDFs', async () => { + const pdfs = await Promise.all([ + createTestPdf(2), + createTestPdf(3), + createTestPdf(1), + ]); + const merged = await mergePdfs(pdfs); + const doc = await PDFDocument.load(merged); + expect(doc.getPageCount()).toBe(6); + }); + + it('should handle single PDF input', async () => { + const pdf = await createTestPdf(3); + const merged = await mergePdfs([pdf]); + const doc = await PDFDocument.load(merged); + expect(doc.getPageCount()).toBe(3); + }); + + it('should handle empty array', async () => { + const merged = await mergePdfs([]); + const doc = await PDFDocument.load(merged); + expect(doc.getPageCount()).toBe(0); + }); + }); + + describe('splitPdf', () => { + it('should extract specific pages', async () => { + const pdf = await createTestPdf(5); + const split = await splitPdf(pdf, [0, 2, 4]); + const doc = await PDFDocument.load(split); + expect(doc.getPageCount()).toBe(3); + }); + + it('should extract single page', async () => { + const pdf = await createTestPdf(5); + const split = await splitPdf(pdf, [2]); + const doc = await PDFDocument.load(split); + expect(doc.getPageCount()).toBe(1); + }); + + it('should handle extracting all pages', async () => { + const pdf = await createTestPdf(3); + const split = await splitPdf(pdf, [0, 1, 2]); + const doc = await PDFDocument.load(split); + expect(doc.getPageCount()).toBe(3); + }); + }); + + describe('deletePdfPages', () => { + it('should delete specified pages', async () => { + const pdf = await createTestPdf(5); + const result = await deletePdfPages(pdf, new Set([1, 3])); + const doc = await PDFDocument.load(result); + expect(doc.getPageCount()).toBe(3); + }); + + it('should delete single page', async () => { + const pdf = await createTestPdf(3); + const result = await deletePdfPages(pdf, new Set([2])); + const doc = await PDFDocument.load(result); + expect(doc.getPageCount()).toBe(2); + }); + + it('should throw when deleting all pages', async () => { + const pdf = await createTestPdf(2); + await expect(deletePdfPages(pdf, new Set([1, 2]))).rejects.toThrow( + 'Cannot delete all pages' + ); + }); + + it('should ignore out-of-range page numbers', async () => { + const pdf = await createTestPdf(3); + const result = await deletePdfPages(pdf, new Set([5, 10])); + const doc = await PDFDocument.load(result); + expect(doc.getPageCount()).toBe(3); + }); + + it('should handle deleting no pages (empty set)', async () => { + const pdf = await createTestPdf(3); + const result = await deletePdfPages(pdf, new Set()); + const doc = await PDFDocument.load(result); + expect(doc.getPageCount()).toBe(3); + }); + }); + + describe('rotatePdfUniform', () => { + it('should rotate all pages by 90 degrees', async () => { + const pdf = await createTestPdf(3); + const rotated = await rotatePdfUniform(pdf, 90); + const doc = await PDFDocument.load(rotated); + expect(doc.getPageCount()).toBe(3); + expect(doc.getPage(0).getRotation().angle).toBe(90); + }); + + it('should rotate by 180 degrees', async () => { + const pdf = await createTestPdf(2); + const rotated = await rotatePdfUniform(pdf, 180); + const doc = await PDFDocument.load(rotated); + expect(doc.getPage(0).getRotation().angle).toBe(180); + }); + + it('should handle 0 degree rotation', async () => { + const pdf = await createTestPdf(2); + const rotated = await rotatePdfUniform(pdf, 0); + const doc = await PDFDocument.load(rotated); + expect(doc.getPage(0).getRotation().angle).toBe(0); + }); + + it('should rotate by 270 degrees', async () => { + const pdf = await createTestPdf(1); + const rotated = await rotatePdfUniform(pdf, 270); + const doc = await PDFDocument.load(rotated); + expect(doc.getPage(0).getRotation().angle).toBe(270); + }); + }); + + describe('rotatePdfPages', () => { + it('should rotate individual pages by different angles', async () => { + const pdf = await createTestPdf(3); + const rotated = await rotatePdfPages(pdf, [90, 0, 180]); + const doc = await PDFDocument.load(rotated); + expect(doc.getPage(0).getRotation().angle).toBe(90); + expect(doc.getPage(1).getRotation().angle).toBe(0); + expect(doc.getPage(2).getRotation().angle).toBe(180); + }); + + it('should treat missing rotations as 0', async () => { + const pdf = await createTestPdf(3); + const rotated = await rotatePdfPages(pdf, [90]); + const doc = await PDFDocument.load(rotated); + expect(doc.getPage(0).getRotation().angle).toBe(90); + expect(doc.getPage(1).getRotation().angle).toBe(0); + expect(doc.getPage(2).getRotation().angle).toBe(0); + }); + + it('should handle all zeros', async () => { + const pdf = await createTestPdf(2); + const rotated = await rotatePdfPages(pdf, [0, 0]); + const doc = await PDFDocument.load(rotated); + expect(doc.getPage(0).getRotation().angle).toBe(0); + expect(doc.getPage(1).getRotation().angle).toBe(0); + }); + }); +}); diff --git a/src/tests/pdf-tools.test.ts b/src/tests/pdf-tools.test.ts index 34b5d0e..cee6868 100644 --- a/src/tests/pdf-tools.test.ts +++ b/src/tests/pdf-tools.test.ts @@ -19,7 +19,7 @@ describe('Tool Configuration Arrays', () => { it('should have the correct number of tools', () => { // This acts as a snapshot test to catch unexpected additions/removals. - expect(singlePdfLoadTools).toHaveLength(41); + expect(singlePdfLoadTools).toHaveLength(42); }); it('should not contain any duplicate tools', () => { @@ -61,7 +61,7 @@ describe('Tool Configuration Arrays', () => { }); it('should have the correct number of tools', () => { - expect(multiFileTools).toHaveLength(13); + expect(multiFileTools).toHaveLength(18); }); it('should not contain any duplicate tools', () => { diff --git a/src/tests/rotate-pdf-page.test.ts b/src/tests/rotate-pdf-page.test.ts new file mode 100644 index 0000000..f42b429 --- /dev/null +++ b/src/tests/rotate-pdf-page.test.ts @@ -0,0 +1,380 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +interface RotateState { + rotations: number[]; +} + +function createTestState(pageCount: number): RotateState { + return { rotations: new Array(pageCount).fill(0) }; +} + +function createPageWrapper( + pageNumber: number, + state: RotateState +): HTMLElement { + const pageIndex = pageNumber - 1; + + const container = document.createElement('div'); + container.className = + 'page-thumbnail relative bg-gray-700 rounded-lg overflow-hidden'; + container.dataset.pageIndex = pageIndex.toString(); + container.dataset.pageNumber = pageNumber.toString(); + + const canvasWrapper = document.createElement('div'); + canvasWrapper.className = + 'thumbnail-wrapper flex items-center justify-center p-2 h-36'; + canvasWrapper.style.transition = 'transform 0.3s ease'; + const initialRotation = state.rotations[pageIndex] || 0; + canvasWrapper.style.transform = `rotate(${initialRotation}deg)`; + + const canvas = document.createElement('canvas'); + canvas.className = 'max-w-full max-h-full object-contain'; + canvasWrapper.appendChild(canvas); + + container.appendChild(canvasWrapper); + + const controls = document.createElement('div'); + controls.className = 'flex items-center justify-center gap-2 p-2 bg-gray-800'; + + const rotateLeftBtn = document.createElement('button'); + rotateLeftBtn.className = + 'rotate-left-btn flex items-center gap-1 px-3 py-1.5 bg-gray-700 hover:bg-gray-600 text-white rounded border border-gray-600 text-xs cursor-pointer'; + rotateLeftBtn.addEventListener('click', function (e) { + e.stopPropagation(); + e.preventDefault(); + state.rotations[pageIndex] = state.rotations[pageIndex] - 90; + const wrapper = container.querySelector( + '.thumbnail-wrapper' + ) as HTMLElement; + if (wrapper) + wrapper.style.transform = `rotate(${state.rotations[pageIndex]}deg)`; + }); + + const rotateRightBtn = document.createElement('button'); + rotateRightBtn.className = + 'rotate-right-btn flex items-center gap-1 px-3 py-1.5 bg-gray-700 hover:bg-gray-600 text-white rounded border border-gray-600 text-xs cursor-pointer'; + rotateRightBtn.addEventListener('click', function (e) { + e.stopPropagation(); + e.preventDefault(); + state.rotations[pageIndex] = state.rotations[pageIndex] + 90; + const wrapper = container.querySelector( + '.thumbnail-wrapper' + ) as HTMLElement; + if (wrapper) + wrapper.style.transform = `rotate(${state.rotations[pageIndex]}deg)`; + }); + + controls.append(rotateLeftBtn, rotateRightBtn); + container.appendChild(controls); + + return container; +} + +function batchRotateAll( + state: RotateState, + angle: number, + containers: HTMLElement[] +) { + for (let i = 0; i < state.rotations.length; i++) { + state.rotations[i] = state.rotations[i] + angle; + } + for (const container of containers) { + const idx = parseInt(container.dataset.pageIndex!, 10); + const wrapper = container.querySelector( + '.thumbnail-wrapper' + ) as HTMLElement; + if (wrapper) wrapper.style.transform = `rotate(${state.rotations[idx]}deg)`; + } +} + +describe('rotate-pdf-page – page wrapper', () => { + let parentContainer: HTMLElement; + + beforeEach(() => { + parentContainer = document.createElement('div'); + parentContainer.id = 'page-thumbnails'; + document.body.appendChild(parentContainer); + }); + + it('should create a page wrapper with correct data attributes', () => { + const state = createTestState(3); + const wrapper = createPageWrapper(1, state); + expect(wrapper.dataset.pageIndex).toBe('0'); + expect(wrapper.dataset.pageNumber).toBe('1'); + }); + + it('should have left and right rotation buttons', () => { + const state = createTestState(1); + const wrapper = createPageWrapper(1, state); + const leftBtn = wrapper.querySelector('.rotate-left-btn'); + const rightBtn = wrapper.querySelector('.rotate-right-btn'); + expect(leftBtn).not.toBeNull(); + expect(rightBtn).not.toBeNull(); + }); + + it('should rotate right by 90° on right-button click', () => { + const state = createTestState(1); + const wrapper = createPageWrapper(1, state); + parentContainer.appendChild(wrapper); + + const rightBtn = wrapper.querySelector('.rotate-right-btn') as HTMLElement; + rightBtn.click(); + + expect(state.rotations[0]).toBe(90); + const tw = wrapper.querySelector('.thumbnail-wrapper') as HTMLElement; + expect(tw.style.transform).toBe('rotate(90deg)'); + }); + + it('should rotate left by -90° on left-button click', () => { + const state = createTestState(1); + const wrapper = createPageWrapper(1, state); + parentContainer.appendChild(wrapper); + + const leftBtn = wrapper.querySelector('.rotate-left-btn') as HTMLElement; + leftBtn.click(); + + expect(state.rotations[0]).toBe(-90); + const tw = wrapper.querySelector('.thumbnail-wrapper') as HTMLElement; + expect(tw.style.transform).toBe('rotate(-90deg)'); + }); + + it('should remain functional after multiple right-button clicks', () => { + const state = createTestState(1); + const wrapper = createPageWrapper(1, state); + parentContainer.appendChild(wrapper); + + const rightBtn = wrapper.querySelector('.rotate-right-btn') as HTMLElement; + + rightBtn.click(); + expect(state.rotations[0]).toBe(90); + + rightBtn.click(); + expect(state.rotations[0]).toBe(180); + + rightBtn.click(); + expect(state.rotations[0]).toBe(270); + + rightBtn.click(); + expect(state.rotations[0]).toBe(360); + + const tw = wrapper.querySelector('.thumbnail-wrapper') as HTMLElement; + expect(tw.style.transform).toBe('rotate(360deg)'); + }); + + it('should remain functional after multiple left-button clicks', () => { + const state = createTestState(1); + const wrapper = createPageWrapper(1, state); + parentContainer.appendChild(wrapper); + + const leftBtn = wrapper.querySelector('.rotate-left-btn') as HTMLElement; + + leftBtn.click(); + expect(state.rotations[0]).toBe(-90); + + leftBtn.click(); + expect(state.rotations[0]).toBe(-180); + + leftBtn.click(); + expect(state.rotations[0]).toBe(-270); + }); + + it('should allow alternating left and right clicks', () => { + const state = createTestState(1); + const wrapper = createPageWrapper(1, state); + parentContainer.appendChild(wrapper); + + const leftBtn = wrapper.querySelector('.rotate-left-btn') as HTMLElement; + const rightBtn = wrapper.querySelector('.rotate-right-btn') as HTMLElement; + + rightBtn.click(); + rightBtn.click(); + leftBtn.click(); + rightBtn.click(); + leftBtn.click(); + leftBtn.click(); + + expect(state.rotations[0]).toBe(0); + }); + + it('should independently rotate different pages', () => { + const state = createTestState(3); + const w1 = createPageWrapper(1, state); + const w2 = createPageWrapper(2, state); + const w3 = createPageWrapper(3, state); + parentContainer.append(w1, w2, w3); + + (w1.querySelector('.rotate-right-btn') as HTMLElement).click(); + (w2.querySelector('.rotate-left-btn') as HTMLElement).click(); + + expect(state.rotations).toEqual([90, -90, 0]); + }); + + it('should allow per-page rotation after a batch rotation', () => { + const state = createTestState(3); + const w1 = createPageWrapper(1, state); + const w2 = createPageWrapper(2, state); + const w3 = createPageWrapper(3, state); + parentContainer.append(w1, w2, w3); + + batchRotateAll(state, 90, [w1, w2, w3]); + expect(state.rotations).toEqual([90, 90, 90]); + + const rightBtn = w1.querySelector('.rotate-right-btn') as HTMLElement; + rightBtn.click(); + expect(state.rotations[0]).toBe(180); + + rightBtn.click(); + expect(state.rotations[0]).toBe(270); + + expect(state.rotations[1]).toBe(90); + expect(state.rotations[2]).toBe(90); + }); + + it('should allow per-page rotation after multiple batch rotations', () => { + const state = createTestState(2); + const w1 = createPageWrapper(1, state); + const w2 = createPageWrapper(2, state); + parentContainer.append(w1, w2); + + batchRotateAll(state, 90, [w1, w2]); + batchRotateAll(state, 90, [w1, w2]); + + expect(state.rotations).toEqual([180, 180]); + + (w1.querySelector('.rotate-left-btn') as HTMLElement).click(); + expect(state.rotations[0]).toBe(90); + + (w1.querySelector('.rotate-left-btn') as HTMLElement).click(); + expect(state.rotations[0]).toBe(0); + }); + + it('should allow batch rotation after per-page rotation', () => { + const state = createTestState(2); + const w1 = createPageWrapper(1, state); + const w2 = createPageWrapper(2, state); + parentContainer.append(w1, w2); + + (w1.querySelector('.rotate-right-btn') as HTMLElement).click(); + expect(state.rotations[0]).toBe(90); + + batchRotateAll(state, -90, [w1, w2]); + expect(state.rotations).toEqual([0, -90]); + + (w2.querySelector('.rotate-right-btn') as HTMLElement).click(); + expect(state.rotations[1]).toBe(0); + }); + + it('should apply correct CSS transform on each click', () => { + const state = createTestState(1); + const wrapper = createPageWrapper(1, state); + parentContainer.appendChild(wrapper); + + const tw = wrapper.querySelector('.thumbnail-wrapper') as HTMLElement; + const rightBtn = wrapper.querySelector('.rotate-right-btn') as HTMLElement; + + expect(tw.style.transform).toBe('rotate(0deg)'); + + rightBtn.click(); + expect(tw.style.transform).toBe('rotate(90deg)'); + + rightBtn.click(); + expect(tw.style.transform).toBe('rotate(180deg)'); + }); + + it('should apply initial rotation from state', () => { + const state = createTestState(2); + state.rotations[0] = 180; + state.rotations[1] = -90; + + const w1 = createPageWrapper(1, state); + const w2 = createPageWrapper(2, state); + + const tw1 = w1.querySelector('.thumbnail-wrapper') as HTMLElement; + const tw2 = w2.querySelector('.thumbnail-wrapper') as HTMLElement; + + expect(tw1.style.transform).toBe('rotate(180deg)'); + expect(tw2.style.transform).toBe('rotate(-90deg)'); + }); + + it('should stop click propagation on rotation buttons', () => { + const state = createTestState(1); + const wrapper = createPageWrapper(1, state); + parentContainer.appendChild(wrapper); + + const parentClickSpy = vi.fn(); + wrapper.addEventListener('click', parentClickSpy); + + const rightBtn = wrapper.querySelector('.rotate-right-btn') as HTMLElement; + rightBtn.click(); + + expect(parentClickSpy).not.toHaveBeenCalled(); + }); + + it('should have cursor-pointer class on rotation buttons', () => { + const state = createTestState(1); + const wrapper = createPageWrapper(1, state); + + const leftBtn = wrapper.querySelector('.rotate-left-btn') as HTMLElement; + const rightBtn = wrapper.querySelector('.rotate-right-btn') as HTMLElement; + + expect(leftBtn.classList.contains('cursor-pointer')).toBe(true); + expect(rightBtn.classList.contains('cursor-pointer')).toBe(true); + }); + + it('should handle rapid successive clicks without losing state', () => { + const state = createTestState(1); + const wrapper = createPageWrapper(1, state); + parentContainer.appendChild(wrapper); + + const rightBtn = wrapper.querySelector('.rotate-right-btn') as HTMLElement; + + for (let i = 0; i < 20; i++) { + rightBtn.click(); + } + + expect(state.rotations[0]).toBe(20 * 90); + }); + + it('should keep working even if button innerHTML is replaced (simulating createIcons)', () => { + const state = createTestState(1); + const wrapper = createPageWrapper(1, state); + parentContainer.appendChild(wrapper); + + const rightBtn = wrapper.querySelector('.rotate-right-btn') as HTMLElement; + + rightBtn.click(); + expect(state.rotations[0]).toBe(90); + + rightBtn.innerHTML = ''; + + rightBtn.click(); + expect(state.rotations[0]).toBe(180); + + rightBtn.click(); + expect(state.rotations[0]).toBe(270); + }); + + it('should keep working after replaceChild on icon element inside button', () => { + const state = createTestState(1); + const wrapper = createPageWrapper(1, state); + parentContainer.appendChild(wrapper); + + const leftBtn = wrapper.querySelector('.rotate-left-btn') as HTMLElement; + + leftBtn.click(); + expect(state.rotations[0]).toBe(-90); + + const oldChild = leftBtn.firstChild; + if (oldChild) { + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('class', 'w-3 h-3'); + leftBtn.replaceChild(svg, oldChild); + } + + leftBtn.click(); + expect(state.rotations[0]).toBe(-180); + + leftBtn.click(); + expect(state.rotations[0]).toBe(-270); + }); +}); diff --git a/src/tests/rotation-state.test.ts b/src/tests/rotation-state.test.ts new file mode 100644 index 0000000..afca040 --- /dev/null +++ b/src/tests/rotation-state.test.ts @@ -0,0 +1,122 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { + getRotationState, + updateRotationState, + resetRotationState, + initializeRotationState, +} from '../js/utils/rotation-state'; + +describe('rotation-state', () => { + beforeEach(() => { + resetRotationState(); + }); + + describe('initializeRotationState', () => { + it('should create array of zeros for given page count', () => { + initializeRotationState(5); + expect(getRotationState()).toEqual([0, 0, 0, 0, 0]); + }); + + it('should handle single page', () => { + initializeRotationState(1); + expect(getRotationState()).toEqual([0]); + }); + + it('should handle zero pages', () => { + initializeRotationState(0); + expect(getRotationState()).toEqual([]); + }); + + it('should reset previous state on re-initialization', () => { + initializeRotationState(3); + updateRotationState(0, 90); + initializeRotationState(5); + expect(getRotationState()).toEqual([0, 0, 0, 0, 0]); + }); + }); + + describe('getRotationState', () => { + it('should return empty array before initialization', () => { + expect(getRotationState()).toEqual([]); + }); + + it('should return readonly array', () => { + initializeRotationState(3); + const state = getRotationState(); + expect(Array.isArray(state)).toBe(true); + }); + + it('should reflect current state after updates', () => { + initializeRotationState(3); + updateRotationState(1, 180); + expect(getRotationState()[1]).toBe(180); + }); + }); + + describe('updateRotationState', () => { + it('should update rotation at valid index', () => { + initializeRotationState(3); + updateRotationState(0, 90); + expect(getRotationState()[0]).toBe(90); + }); + + it('should update to any rotation value', () => { + initializeRotationState(3); + updateRotationState(0, 90); + updateRotationState(1, 180); + updateRotationState(2, 270); + expect(getRotationState()).toEqual([90, 180, 270]); + }); + + it('should handle negative rotation', () => { + initializeRotationState(2); + updateRotationState(0, -90); + expect(getRotationState()[0]).toBe(-90); + }); + + it('should ignore negative index', () => { + initializeRotationState(3); + updateRotationState(-1, 90); + expect(getRotationState()).toEqual([0, 0, 0]); + }); + + it('should ignore out-of-bounds index', () => { + initializeRotationState(3); + updateRotationState(5, 90); + expect(getRotationState()).toEqual([0, 0, 0]); + }); + + it('should ignore update on empty state', () => { + updateRotationState(0, 90); + expect(getRotationState()).toEqual([]); + }); + + it('should allow overwriting a previously set rotation', () => { + initializeRotationState(2); + updateRotationState(0, 90); + updateRotationState(0, 180); + expect(getRotationState()[0]).toBe(180); + }); + }); + + describe('resetRotationState', () => { + it('should clear all state', () => { + initializeRotationState(5); + updateRotationState(2, 270); + resetRotationState(); + expect(getRotationState()).toEqual([]); + }); + + it('should be safe to call on empty state', () => { + resetRotationState(); + expect(getRotationState()).toEqual([]); + }); + + it('should allow re-initialization after reset', () => { + initializeRotationState(3); + resetRotationState(); + initializeRotationState(2); + expect(getRotationState()).toEqual([0, 0]); + }); + }); +}); diff --git a/src/tests/setup.ts b/src/tests/setup.ts index dc4c349..9cb4e3e 100644 --- a/src/tests/setup.ts +++ b/src/tests/setup.ts @@ -1,5 +1,18 @@ import { afterEach, vi } from 'vitest'; +class TestDOMMatrix { + a = 1; + b = 0; + c = 0; + d = 1; + e = 0; + f = 0; +} + +if (typeof globalThis.DOMMatrix === 'undefined') { + globalThis.DOMMatrix = TestDOMMatrix as unknown as typeof DOMMatrix; +} + afterEach(() => { document.body.innerHTML = ''; document.head.innerHTML = ''; diff --git a/src/tests/tesseract-runtime.test.ts b/src/tests/tesseract-runtime.test.ts new file mode 100644 index 0000000..748aaa7 --- /dev/null +++ b/src/tests/tesseract-runtime.test.ts @@ -0,0 +1,128 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { createWorker } = vi.hoisted(() => ({ + createWorker: vi.fn(), +})); + +vi.mock('tesseract.js', () => ({ + default: { + createWorker, + }, +})); + +import { + buildTesseractWorkerOptions, + createConfiguredTesseractWorker, + getIncompleteTesseractOverrideKeys, + hasCompleteTesseractOverrides, + hasConfiguredTesseractOverrides, + resolveTesseractAssetConfig, +} from '../js/utils/tesseract-runtime'; +import { + assertTesseractLanguagesAvailable, + getAvailableTesseractLanguageEntries, + getUnavailableTesseractLanguages, + UnsupportedOcrLanguageError, +} from '../js/utils/tesseract-language-availability'; + +describe('tesseract-runtime', () => { + beforeEach(() => { + createWorker.mockReset(); + }); + + it('normalizes self-hosted OCR asset URLs', () => { + const config = resolveTesseractAssetConfig({ + VITE_TESSERACT_WORKER_URL: + 'https://internal.example.com/ocr/worker.min.js/', + VITE_TESSERACT_CORE_URL: 'https://internal.example.com/ocr/core/', + VITE_TESSERACT_LANG_URL: 'https://internal.example.com/ocr/lang-data/', + }); + + expect(config).toEqual({ + workerPath: 'https://internal.example.com/ocr/worker.min.js', + corePath: 'https://internal.example.com/ocr/core', + langPath: 'https://internal.example.com/ocr/lang-data', + }); + expect(hasConfiguredTesseractOverrides(config)).toBe(true); + expect(hasCompleteTesseractOverrides(config)).toBe(true); + }); + + it('returns logger-only options when no self-hosted OCR assets are configured', () => { + const logger = vi.fn(); + + expect(buildTesseractWorkerOptions(logger, {})).toEqual({ logger }); + expect( + hasConfiguredTesseractOverrides(resolveTesseractAssetConfig({})) + ).toBe(false); + }); + + it('throws on partial OCR asset configuration', () => { + const env = { + VITE_TESSERACT_WORKER_URL: + 'https://internal.example.com/ocr/worker.min.js', + VITE_TESSERACT_CORE_URL: 'https://internal.example.com/ocr/core', + }; + + expect( + getIncompleteTesseractOverrideKeys(resolveTesseractAssetConfig(env)) + ).toEqual(['VITE_TESSERACT_LANG_URL']); + expect(() => buildTesseractWorkerOptions(undefined, env)).toThrow( + 'Self-hosted OCR assets are partially configured' + ); + }); + + it('passes configured OCR asset URLs to Tesseract.createWorker', async () => { + const logger = vi.fn(); + createWorker.mockResolvedValue({ id: 'worker' }); + + await createConfiguredTesseractWorker('eng', 1, logger, { + VITE_TESSERACT_WORKER_URL: + 'https://internal.example.com/ocr/worker.min.js', + VITE_TESSERACT_CORE_URL: 'https://internal.example.com/ocr/core', + VITE_TESSERACT_LANG_URL: 'https://internal.example.com/ocr/lang-data', + }); + + expect(createWorker).toHaveBeenCalledWith('eng', 1, { + logger, + workerPath: 'https://internal.example.com/ocr/worker.min.js', + corePath: 'https://internal.example.com/ocr/core', + langPath: 'https://internal.example.com/ocr/lang-data', + gzip: true, + }); + }); + + it('filters OCR language entries when the build restricts bundled languages', () => { + expect( + getAvailableTesseractLanguageEntries({ + VITE_TESSERACT_AVAILABLE_LANGUAGES: 'eng,deu', + }) + ).toEqual([ + ['eng', 'English'], + ['deu', 'German'], + ]); + }); + + it('reports unavailable OCR languages for restricted air-gap builds', () => { + expect( + getUnavailableTesseractLanguages('eng+fra', { + VITE_TESSERACT_AVAILABLE_LANGUAGES: 'eng,deu', + }) + ).toEqual(['fra']); + + expect(() => + assertTesseractLanguagesAvailable('eng+fra', { + VITE_TESSERACT_AVAILABLE_LANGUAGES: 'eng,deu', + }) + ).toThrow(UnsupportedOcrLanguageError); + }); + + it('blocks worker creation when OCR requests an unbundled language', async () => { + await expect( + createConfiguredTesseractWorker('fra', 1, undefined, { + VITE_TESSERACT_AVAILABLE_LANGUAGES: 'eng,deu', + }) + ).rejects.toThrow('This BentoPDF build only bundles OCR data for'); + + expect(createWorker).not.toHaveBeenCalled(); + }); +}); diff --git a/src/tests/xml-to-pdf.test.ts b/src/tests/xml-to-pdf.test.ts new file mode 100644 index 0000000..00aefd6 --- /dev/null +++ b/src/tests/xml-to-pdf.test.ts @@ -0,0 +1,255 @@ +import { describe, it, expect } from 'vitest'; + +function groupByTagName(elements: Element[]): Record { + const groups: Record = {}; + for (const element of elements) { + const tagName = element.tagName; + if (!groups[tagName]) { + groups[tagName] = []; + } + groups[tagName].push(element); + } + return groups; +} + +function extractTableData(elements: Element[]): { + headers: string[]; + rows: string[][]; +} { + if (elements.length === 0) { + return { headers: [], rows: [] }; + } + const headerSet = new Set(); + for (const element of elements) { + for (const child of Array.from(element.children)) { + headerSet.add(child.tagName); + } + } + const headers = Array.from(headerSet); + const rows: string[][] = []; + for (const element of elements) { + const row: string[] = []; + for (const header of headers) { + const child = element.querySelector(header); + row.push(child?.textContent?.trim() || ''); + } + rows.push(row); + } + return { headers, rows }; +} + +function extractKeyValuePairs(element: Element): string[][] { + const pairs: string[][] = []; + for (const child of Array.from(element.children)) { + const key = child.tagName; + const value = child.textContent?.trim() || ''; + if (value) { + pairs.push([formatTitle(key), value]); + } + } + for (const attr of Array.from(element.attributes)) { + pairs.push([formatTitle(attr.name), attr.value]); + } + return pairs; +} + +function formatTitle(tagName: string): string { + return tagName + .replace(/[_-]/g, ' ') + .replace(/([a-z])([A-Z])/g, '$1 $2') + .split(' ') + .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + .join(' '); +} + +function parseXml(xmlString: string): Document { + return new DOMParser().parseFromString(xmlString, 'text/xml'); +} + +describe('xml-to-pdf utilities', () => { + describe('formatTitle', () => { + it('should convert underscores to spaces and capitalize', () => { + expect(formatTitle('first_name')).toBe('First Name'); + }); + + it('should convert hyphens to spaces and capitalize', () => { + expect(formatTitle('last-name')).toBe('Last Name'); + }); + + it('should split camelCase', () => { + expect(formatTitle('firstName')).toBe('First Name'); + }); + + it('should handle single word', () => { + expect(formatTitle('name')).toBe('Name'); + }); + + it('should handle all caps', () => { + expect(formatTitle('ID')).toBe('Id'); + }); + + it('should handle mixed separators', () => { + expect(formatTitle('user_firstName')).toBe('User First Name'); + }); + + it('should handle empty string', () => { + expect(formatTitle('')).toBe(''); + }); + + it('should handle multiple underscores', () => { + expect(formatTitle('a_b_c')).toBe('A B C'); + }); + + it('should lowercase subsequent characters', () => { + expect(formatTitle('XML')).toBe('Xml'); + }); + }); + + describe('groupByTagName', () => { + it('should group elements by tag name', () => { + const doc = parseXml( + '123' + ); + const children = Array.from(doc.documentElement.children); + const groups = groupByTagName(children); + expect(Object.keys(groups)).toEqual(['item', 'other']); + expect(groups['item'].length).toBe(2); + expect(groups['other'].length).toBe(1); + }); + + it('should handle empty array', () => { + expect(groupByTagName([])).toEqual({}); + }); + + it('should handle single element', () => { + const doc = parseXml('1'); + const children = Array.from(doc.documentElement.children); + const groups = groupByTagName(children); + expect(Object.keys(groups)).toEqual(['item']); + expect(groups['item'].length).toBe(1); + }); + + it('should handle all same tag names', () => { + const doc = parseXml('123'); + const children = Array.from(doc.documentElement.children); + const groups = groupByTagName(children); + expect(Object.keys(groups)).toEqual(['row']); + expect(groups['row'].length).toBe(3); + }); + + it('should handle all different tag names', () => { + const doc = parseXml('123'); + const children = Array.from(doc.documentElement.children); + const groups = groupByTagName(children); + expect(Object.keys(groups).length).toBe(3); + }); + }); + + describe('extractTableData', () => { + it('should extract headers and rows from elements', () => { + const doc = parseXml(` + + Alice30 + Bob25 + + `); + const elements = Array.from(doc.querySelectorAll('person')); + const { headers, rows } = extractTableData(elements); + expect(headers).toEqual(['name', 'age']); + expect(rows).toEqual([ + ['Alice', '30'], + ['Bob', '25'], + ]); + }); + + it('should handle empty array', () => { + expect(extractTableData([])).toEqual({ headers: [], rows: [] }); + }); + + it('should handle missing children in some elements', () => { + const doc = parseXml(` + + 12 + 3 + + `); + const elements = Array.from(doc.querySelectorAll('item')); + const { headers, rows } = extractTableData(elements); + expect(headers).toEqual(['a', 'b']); + expect(rows[1]).toEqual(['3', '']); + }); + + it('should handle elements with no children', () => { + const doc = parseXml(''); + const elements = Array.from(doc.querySelectorAll('item')); + const { headers, rows } = extractTableData(elements); + expect(headers).toEqual([]); + expect(rows).toEqual([[]]); + }); + + it('should collect headers from all elements', () => { + const doc = parseXml(` + + 1 + 2 + + `); + const elements = Array.from(doc.querySelectorAll('item')); + const { headers } = extractTableData(elements); + expect(headers).toContain('a'); + expect(headers).toContain('b'); + }); + + it('should trim whitespace from text content', () => { + const doc = parseXml(' hello '); + const elements = Array.from(doc.querySelectorAll('item')); + const { rows } = extractTableData(elements); + expect(rows[0][0]).toBe('hello'); + }); + }); + + describe('extractKeyValuePairs', () => { + it('should extract child elements as key-value pairs', () => { + const doc = parseXml( + 'Test1.0' + ); + const pairs = extractKeyValuePairs(doc.documentElement); + expect(pairs).toEqual([ + ['Name', 'Test'], + ['Version', '1.0'], + ]); + }); + + it('should extract attributes as key-value pairs', () => { + const doc = parseXml(''); + const pairs = extractKeyValuePairs(doc.documentElement); + expect(pairs).toContainEqual(['Id', '123']); + expect(pairs).toContainEqual(['Type', 'main']); + }); + + it('should include both children and attributes', () => { + const doc = parseXml('Test'); + const pairs = extractKeyValuePairs(doc.documentElement); + expect(pairs.length).toBe(2); + }); + + it('should skip empty child text content', () => { + const doc = parseXml('Test'); + const pairs = extractKeyValuePairs(doc.documentElement); + expect(pairs.length).toBe(1); + expect(pairs[0][0]).toBe('Name'); + }); + + it('should handle element with no children or attributes', () => { + const doc = parseXml(''); + const pairs = extractKeyValuePairs(doc.documentElement); + expect(pairs).toEqual([]); + }); + + it('should format tag names using formatTitle', () => { + const doc = parseXml('Alice'); + const pairs = extractKeyValuePairs(doc.documentElement); + expect(pairs[0][0]).toBe('User Name'); + }); + }); +}); diff --git a/src/types/globals.d.ts b/src/types/globals.d.ts index 48c971c..aee6f2f 100644 --- a/src/types/globals.d.ts +++ b/src/types/globals.d.ts @@ -1 +1,15 @@ +/// + +interface ImportMetaEnv { + readonly VITE_TESSERACT_WORKER_URL?: string; + readonly VITE_TESSERACT_CORE_URL?: string; + readonly VITE_TESSERACT_LANG_URL?: string; + readonly VITE_TESSERACT_AVAILABLE_LANGUAGES?: string; + readonly VITE_OCR_FONT_BASE_URL?: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} + declare const __SIMPLE_MODE__: boolean; diff --git a/tasks/lessons.md b/tasks/lessons.md new file mode 100644 index 0000000..2fd09f5 --- /dev/null +++ b/tasks/lessons.md @@ -0,0 +1,13 @@ +- Compare tool overlay regressions: check shared CSS before changing page logic; global canvas positioning rules can hide rendered PDF content while leaving highlight layers visible. +- When hardening code after a type-safety follow-up, never leave empty `catch {}` blocks in the touched path. Either guard the risky call up front or catch the error into a variable and handle it intentionally with a safe fallback, warning, or typed default. +- When adding or refining form creator models, keep reusable types and interfaces in `src/js/types` instead of defining them inline in logic files or tests. Logic modules should import shared types rather than owning them. +- Shared app types must be exported from `src/js/types/index.ts` and imported through the `@/types` alias. Do not import shared types from individual type files or relative `types/index` paths in feature code or tests. +- After a user correction about type safety, capture the lesson immediately: never leave `any` in a bug fix if the surrounding library types can be modeled or narrowed. Prefer extracting the logic into a typed helper and adding regression tests for the corrected path. +- pdf.js assigns document-scoped font name prefixes (`g_d0_`, `g_d1_`, ...) per loaded document. Always normalize these before comparing font names across documents to avoid false positive style changes. +- LibreOffice PDF conversion support can depend on explicit import filters. Before concluding `soffice` or LibreOffice WASM cannot convert `pdf -> docx/pptx`, test the exact filtered path such as `--infilter=writer_pdf_import` or `--infilter=impress_pdf_import` and inspect wrapper code for hardcoded capability gates. +- LibreOfficeKit `documentLoadWithOptions()` is not the same as CLI `--infilter`. In the current WASM/LOK build, the options string is forwarded as `FilterOptions`, not media-descriptor `FilterName`, so passing `FilterName=writer_pdf_import` from JS does not force PDF to load as Writer or Impress. +- When a consolidated patch contains unrelated features (e.g. abort API + PDF filter fix), a compile failure in one breaks the whole build. Never bundle unrelated features in one patch — split by concern. If already bundled, surgically remove the broken feature from the single patch rather than layering a second "undo" patch on top. +- Never add new C++ APIs (enums, functions) to a WASM build patch without confirming the header declarations are visible at compile time in all translation units that use them. The `OperationType` enum was declared in `lok.hxx` but the `.cxx` failed because of include ordering or missing header propagation. +- When removing hunks from a unified diff patch: (1) update hunk line counts in `@@` headers, (2) remove entire file sections if no +/- lines remain, (3) verify the old-side line numbers still match the actual source (removing earlier hunks shifts offsets), (4) verify the `-` lines match the actual source text character-for-character (e.g. `xInteraction` vs `uno::Reference(pInteraction)`). +- ALWAYS check the existing API before rebuilding WASM or forking libraries. The matbee libreoffice-converter already had `inputFilter` support in ConversionOptions, browser.worker.ts, and buildLoadOptions(). The entire WASM rebuild was unnecessary and replacing browser.worker.global.js with a fork build caused a DeploymentException that broke all LibreOffice conversions. +- Never replace compiled vendor assets (browser.worker.global.js, soffice.wasm.gz, etc.) unless absolutely necessary. These are tightly coupled and a mismatched worker JS + WASM binary causes initialization failures. diff --git a/tools.html b/tools.html index 9af00e9..7f64800 100644 --- a/tools.html +++ b/tools.html @@ -508,6 +508,12 @@ category: 'editor', icon: 'list-numbers', }, + { + name: 'bates-numbering', + title: 'Bates Numbering', + category: 'editor', + icon: 'hash', + }, { name: 'background-color', title: 'Background Color', @@ -821,7 +827,7 @@ grid.innerHTML = filtered .map( (tool) => ` - +

    ${tool.title}

    diff --git a/unraid_bentopdf.xml b/unraid_bentopdf.xml index 9d48d1f..bb716b2 100644 --- a/unraid_bentopdf.xml +++ b/unraid_bentopdf.xml @@ -1,8 +1,8 @@ bentopdf - bentopdf/bentopdf - https://hub.docker.com/r/bentopdf/bentopdf/ + bentopdfteam/bentopdf + https://hub.docker.com/r/bentopdfteam/bentopdf/ bridge sh @@ -12,7 +12,7 @@ Uploaded on behalf of creator: alam00000 BentoPDF is a privacy first PDF Toolkit -https://hub.docker.com/r/bentopdf/bentopdf/ +https://hub.docker.com/r/bentopdfteam/bentopdf/ Tools: http://[IP]:[PORT:8080]/ diff --git a/vendor/embedpdf/.upstream-version b/vendor/embedpdf/.upstream-version index bc80560..276cbf9 100644 --- a/vendor/embedpdf/.upstream-version +++ b/vendor/embedpdf/.upstream-version @@ -1 +1 @@ -1.5.0 +2.3.0 diff --git a/vendor/embedpdf/embedpdf-snippet-1.5.0.tgz b/vendor/embedpdf/embedpdf-snippet-1.5.0.tgz deleted file mode 100644 index 0445f11..0000000 Binary files a/vendor/embedpdf/embedpdf-snippet-1.5.0.tgz and /dev/null differ diff --git a/vendor/embedpdf/embedpdf-snippet-2.3.0.tgz b/vendor/embedpdf/embedpdf-snippet-2.3.0.tgz new file mode 100644 index 0000000..c671451 Binary files /dev/null and b/vendor/embedpdf/embedpdf-snippet-2.3.0.tgz differ diff --git a/vendor/sheetjs/xlsx-0.20.2.tgz b/vendor/sheetjs/xlsx-0.20.2.tgz deleted file mode 100644 index d3ee3a2..0000000 Binary files a/vendor/sheetjs/xlsx-0.20.2.tgz and /dev/null differ diff --git a/vite.config.ts b/vite.config.ts index 0375820..5b1e410 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,6 +1,7 @@ import { defineConfig, Plugin } from 'vitest/config'; import type { IncomingMessage, ServerResponse } from 'http'; import type { Connect } from 'vite'; +import basicSsl from '@vitejs/plugin-basic-ssl'; import tailwindcss from '@tailwindcss/vite'; import { nodePolyfills } from 'vite-plugin-node-polyfills'; import { viteStaticCopy } from 'vite-plugin-static-copy'; @@ -13,16 +14,22 @@ import type { OutputBundle } from 'rollup'; const SUPPORTED_LANGUAGES = [ 'en', + 'ar', + 'be', + 'da', 'de', 'es', + 'fr', + 'id', + 'it', + 'nl', + 'pt', + 'sv', + 'tr', + 'vi', 'zh', 'zh-TW', - 'vi', - 'it', - 'id', - 'tr', - 'fr', - 'pt', + 'ko', ] as const; const LANG_REGEX = new RegExp( `^/(${SUPPORTED_LANGUAGES.join('|')})(?:/(.*))?$` @@ -274,34 +281,6 @@ export default defineConfig(() => { } const staticCopyTargets = [ - { - src: 'node_modules/@bentopdf/pymupdf-wasm/assets/*.wasm', - dest: 'pymupdf-wasm', - }, - { - src: 'node_modules/@bentopdf/pymupdf-wasm/assets/*.js', - dest: 'pymupdf-wasm', - }, - { - src: 'node_modules/@bentopdf/pymupdf-wasm/assets/*.whl', - dest: 'pymupdf-wasm', - }, - { - src: 'node_modules/@bentopdf/pymupdf-wasm/assets/*.zip', - dest: 'pymupdf-wasm', - }, - { - src: 'node_modules/@bentopdf/pymupdf-wasm/assets/*.json', - dest: 'pymupdf-wasm', - }, - { - src: 'node_modules/@bentopdf/gs-wasm/assets/*.wasm', - dest: 'ghostscript-wasm', - }, - { - src: 'node_modules/@bentopdf/gs-wasm/assets/*.js', - dest: 'ghostscript-wasm', - }, { src: 'node_modules/embedpdf-snippet/dist/pdfium.wasm', dest: 'embedpdf', @@ -311,11 +290,15 @@ export default defineConfig(() => { return { base: (process.env.BASE_URL || '/').replace(/\/?$/, '/'), plugins: [ + // basicSsl(), handlebars({ partialDirectory: resolve(__dirname, 'src/partials'), context: { baseUrl: (process.env.BASE_URL || '/').replace(/\/?$/, '/'), simpleMode: process.env.SIMPLE_MODE === 'true', + brandName: process.env.VITE_BRAND_NAME || '', + brandLogo: process.env.VITE_BRAND_LOGO || '', + footerText: process.env.VITE_FOOTER_TEXT || '', }, }), languageRouterPlugin(), @@ -357,6 +340,7 @@ export default defineConfig(() => { ], define: { __SIMPLE_MODE__: JSON.stringify(process.env.SIMPLE_MODE === 'true'), + __BRAND_NAME__: JSON.stringify(process.env.VITE_BRAND_NAME || ''), }, resolve: { alias: { @@ -428,6 +412,9 @@ export default defineConfig(() => { 'add-watermark': resolve(__dirname, 'src/pages/add-watermark.html'), 'header-footer': resolve(__dirname, 'src/pages/header-footer.html'), 'invert-colors': resolve(__dirname, 'src/pages/invert-colors.html'), + 'scanner-effect': resolve(__dirname, 'src/pages/scanner-effect.html'), + 'pdf-workflow': resolve(__dirname, 'src/pages/pdf-workflow.html'), + 'adjust-colors': resolve(__dirname, 'src/pages/adjust-colors.html'), 'background-color': resolve( __dirname, 'src/pages/background-color.html' @@ -574,6 +561,11 @@ export default defineConfig(() => { 'src/pages/font-to-outline.html' ), 'deskew-pdf': resolve(__dirname, 'src/pages/deskew-pdf.html'), + 'wasm-settings': resolve(__dirname, 'src/pages/wasm-settings.html'), + 'bates-numbering': resolve( + __dirname, + 'src/pages/bates-numbering.html' + ), }, }, },